Переименование jira-hub → sdlc-hub (#97) #99

Merged
aep merged 21 commits from feat/97-sdlc-rename into main 2026-10-24 14:35:47 +03:00
134 changed files with 2609 additions and 340 deletions

2
.gitignore vendored
View File

@ -12,7 +12,7 @@ __pycache__/
*.sqlite* *.sqlite*
.pytest_cache/ .pytest_cache/
.wt/ .wt/
src/jira_hub/web/app/ src/sdlc_hub/web/app/
web/test-results/ web/test-results/
web/playwright-report/ web/playwright-report/
web/.e2e-hub/ web/.e2e-hub/

View File

@ -4,7 +4,7 @@
## Ядро и модули ## Ядро и модули
Хаб — ядро (`src/jira_hub/core/`) и подключаемые модули (`src/jira_hub/modules/<имя>/`). Ядро держит механику: базу, Хаб — ядро (`src/sdlc_hub/core/`) и подключаемые модули (`src/sdlc_hub/modules/<имя>/`). Ядро держит механику: базу,
очередь команд, HTTP, настройки, реле, выпуск агента, каркас MCP и CLI. Модуль даёт смысл: схему своих таблиц, типы очередь команд, HTTP, настройки, реле, выпуск агента, каркас MCP и CLI. Модуль даёт смысл: схему своих таблиц, типы
команд, маршруты, настройки, MCP-инструменты, подкоманды. Сейчас модулей два — `jira` и `wiki`; следующие (sdlc) подключаются команд, маршруты, настройки, MCP-инструменты, подкоманды. Сейчас модулей два — `jira` и `wiki`; следующие (sdlc) подключаются
так же, строкой в `core/registry.py`. так же, строкой в `core/registry.py`.
@ -13,7 +13,7 @@
| Модуль | Ответственность | Зависит от | | Модуль | Ответственность | Зависит от |
|---|---|---| |---|---|---|
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib | | `paths` | каталог данных (`SDLC_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
| `errors` | ошибки, общие для ядра и модулей (`CommandError`, `HttpMapped`) | stdlib | | `errors` | ошибки, общие для ядра и модулей (`CommandError`, `HttpMapped`) | stdlib |
| `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` | | `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` |
| `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib | | `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib |
@ -29,7 +29,7 @@
| `release` | опубликованная версия агента модуля `release/<модуль>/agent.py` + `manifest.json`: `publish` (только вверх), `current`, `load`, `hint` | `paths` | | `release` | опубликованная версия агента модуля `release/<модуль>/agent.py` + `manifest.json`: `publish` (только вверх), `current`, `load`, `hint` | `paths` |
| `client` | HTTP-клиент хаба (`HubClient`, `from_home`, `get_bytes`) | `db`, `paths`, `settings` (только `from_home`) | | `client` | HTTP-клиент хаба (`HubClient`, `from_home`, `get_bytes`) | `db`, `paths`, `settings` (только `from_home`) |
| `mcp` | каркас MCP: охрана («хаб выключен» — подсказка), привязка сессии к проекту (`PROJECT`), общие инструменты `projects` и `project_create`, `register_core_tools`, запуск сервера модуля по stdio | `client`, `registry` | | `mcp` | каркас MCP: охрана («хаб выключен» — подсказка), привязка сессии к проекту (`PROJECT`), общие инструменты `projects` и `project_create`, `register_core_tools`, запуск сервера модуля по stdio | `client`, `registry` |
| `cli` | `init`, `serve`, `mcp [модуль] [--project]`, `tokens`, `agent publish [--module]` и подкоманды модулей (`Module.cli_register`/`cli_run`) | `api`, `mcp`, `registry`, `release`, `settings`, `db`, `paths` | | `cli` | `init`, `serve`, `mcp <модуль> [--project]`, `tokens`, `agent publish [--module]` и подкоманды модулей (`Module.cli_register`/`cli_run`) | `api`, `mcp`, `registry`, `release`, `settings`, `db`, `paths` |
### Модуль `jira` (`modules/jira/`) ### Модуль `jira` (`modules/jira/`)
@ -49,7 +49,7 @@
| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | | `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` |
| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | | `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` |
| `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` |
| `importer`, `cli` | разовый импорт CSV-пакета в заметки (`jira-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` |
### Модуль `wiki` (`modules/wiki/`) ### Модуль `wiki` (`modules/wiki/`)
@ -69,7 +69,7 @@
| `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | | `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` | | `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` | | `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` | инструменты сервера `jira-hub-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` | | `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` |
### Контракт модуля ### Контракт модуля
@ -106,7 +106,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
`feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `refresh/` (кнопка и плашка `feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `refresh/` (кнопка и плашка
обновления данных из Jira в боковой панели, отслеживание пакета: `RefreshProvider`, `useJiraRefresh`), `api/queries.ts`. обновления данных из Jira в боковой панели, отслеживание пакета: `RefreshProvider`, `useJiraRefresh`), `api/queries.ts`.
Собирается `make web` в `src/jira_hub/web/app/`; `core/api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие Собирается `make web` в `src/sdlc_hub/web/app/`; `core/api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие
`/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с
клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `board/model`. клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `board/model`.
@ -129,11 +129,11 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
- Синхронизация вики: `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` → отчёт `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`), копия прежняя.
- Вложение вики: `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-<id>/<имя>`. - Вложение вики: `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-<id>/<имя>`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Самообновление агента: `jira-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. - Самообновление агента: `sdlc-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
- Токены: клиентский — всё, кроме `/agent/*`; по токену на каждый модуль-агент (`jira`, `wiki`) — только `/agent/*`, причём маршруты `scope=agent` модуля пускают лишь его агента (токен чужого — 401); `claim` выдаёт агенту только команды его модуля, по одной в полёте на агента; `agent_status` (ключ — имя агента), релиз `release/<модуль>/` и самообновление — по агентам; MCP берёт клиентский токен из базы через `HubClient.from_home`. Токен Eva в хабе не хранится — только в `agent.json` eva-agent. - Токены: клиентский — всё, кроме `/agent/*`; по токену на каждый модуль-агент (`jira`, `wiki`) — только `/agent/*`, причём маршруты `scope=agent` модуля пускают лишь его агента (токен чужого — 401); `claim` выдаёт агенту только команды его модуля, по одной в полёте на агента; `agent_status` (ключ — имя агента), релиз `release/<модуль>/` и самообновление — по агентам; MCP берёт клиентский токен из базы через `HubClient.from_home`. Токен Eva в хабе не хранится — только в `agent.json` eva-agent.
## Границы ## Границы
- Ядро (`core/`) не импортирует модули (`jira_hub.modules.*`): исключения — `core/registry.py` (ленивый импорт внутри функции) и шаги истории миграций в `core/db.py` (ленивый импорт внутри шага). Проверяет `tests/test_registry.py::test_core_imports`. - Ядро (`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`). Меток-исключений «долг» нет. - Типы команд (`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`. - Новые имена в ядре — без названий модулей. Модуль не вызывается из ядра иначе как через `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 не обращается к Eva и не хранит токен Eva.
@ -146,8 +146,8 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. Модуль веба не импортирует другой модуль; общее — в `shell`, `ui`, `api`, `projects`. - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. Модуль веба не импортирует другой модуль; общее — в `shell`, `ui`, `api`, `projects`.
## Выкатка и откат версии со схемой v7 ## Выкатка и откат версии со схемой v7
- Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить. - Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить.
- До kickstart не запускать MCP и CLI `jira-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её до v7, а хаб на прежнем коде (v6) с такой базой не работает (`agent_status`, токены). - До kickstart не запускать MCP и CLI `sdlc-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её до v7, а хаб на прежнем коде (v6) с такой базой не работает (`agent_status`, токены).
- Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит. - Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит.
## Открытые вопросы ## Открытые вопросы
@ -157,4 +157,4 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
- История страниц вики и частичная перекачка: сейчас каждая синхронизация перекачивает всё дерево, копия без истории. - История страниц вики и частичная перекачка: сейчас каждая синхронизация перекачивает всё дерево, копия без истории.
- Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `PATCH /projects`). - Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `PATCH /projects`).
- Ручной просмотр вёрстки веб-страницы «Вики» на живых данных (таблицы, вложенные списки) — за владельцем после выкатки. - Ручной просмотр вёрстки веб-страницы «Вики» на живых данных (таблицы, вложенные списки) — за владельцем после выкатки.
- Переименование хаба в sdlc-hub (модули jira + wiki, eva-agent) — после перестройки кода; имена пакета, команды и MCP-сервера пока `jira-hub`. - Хаб переименован в 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`.

View File

@ -1,21 +1,21 @@
# jira-hub # sdlc-hub
## Что это ## Что это
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проектов-меток Jira-проекта AURORA на Mac mini (`192.168.0.106`) и Markdown-копии вики Eva по проектам (модуль `wiki`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: единственный источник истины по задачам проектов-меток Jira-проекта AURORA на Mac mini (`192.168.0.106`) и Markdown-копии вики Eva по проектам (модуль `wiki`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`).
## Стек и ограничения ## Стек и ограничения
- Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git. - Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/sdlc_hub/web/app/` не в git.
- Python 3.13 от uv (`.python-version`, `tool.uv.python-preference = "only-managed"`: системный Python miniconda несёт SQLite 3.51.1 с взаимной блокировкой, O-14 #29), uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10). - Python 3.13 от uv (`.python-version`, `tool.uv.python-preference = "only-managed"`: системный Python miniconda несёт SQLite 3.51.1 с взаимной блокировкой, O-14 #29), uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/<модуль>/` (опубликованная версия агента модуля), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `<id>/<имя>`, у wiki `wiki-<id>/<имя>`, уборка 30 дней), `wiki/<метка>/` (Markdown-копия вики проекта: `index.md` в каталоге страницы, `_tree.json`); каталог данных, `backups/`, `release/<модуль>/`, `files/`, `downloads/` и `wiki/` — права 0700. Порт по умолчанию 8790. - Данные: `~/.local/share/sdlc-hub/` (`SDLC_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/<модуль>/` (опубликованная версия агента модуля), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `<id>/<имя>`, у wiki `wiki-<id>/<имя>`, уборка 30 дней), `wiki/<метка>/` (Markdown-копия вики проекта: `index.md` в каталоге страницы, `_tree.json`); каталог данных, `backups/`, `release/<модуль>/`, `files/`, `downloads/` и `wiki/` — права 0700. Порт по умолчанию 8790.
- Статические токены (в таблице `setting`): клиентский — всё, кроме `/agent/*`; по одному токену агента на модуль (`jira`, `wiki`) — только `/agent/*` и только маршруты своего модуля. Токен Eva в хабе не хранится. - Статические токены (в таблице `setting`): клиентский — всё, кроме `/agent/*`; по одному токену агента на модуль (`jira`, `wiki`) — только `/agent/*` и только маршруты своего модуля. Токен Eva в хабе не хранится.
## Архитектура ## Архитектура
- `src/jira_hub/core/` — ядро: `db`, `settings`, `projects`, `queue` (механика очереди), `files`, `api` (HTTP, `CORE_ROUTES` + маршруты модулей в `ROUTES`, фоновый `tick`), `relay`, `release`, `client`, `mcp` (каркас MCP), `cli`; контракт модуля `Module`/`CommandHooks` — `core/modules.py`, реестр — `core/registry.py`. - `src/sdlc_hub/core/` — ядро: `db`, `settings`, `projects`, `queue` (механика очереди), `files`, `api` (HTTP, `CORE_ROUTES` + маршруты модулей в `ROUTES`, фоновый `tick`), `relay`, `release`, `client`, `mcp` (каркас MCP), `cli`; контракт модуля `Module`/`CommandHooks` — `core/modules.py`, реестр — `core/registry.py`.
- `src/jira_hub/modules/jira/` — модуль jira: `schema`, `mirror`, `history`, `attachments`, `fetch`, `commands`, `refresh`, `projects`, `hooks` (смысл типов команд для очереди), `api`, `mcp`, `settings`, `importer`/`cli`. Входная точка — `MODULE` в `modules/jira/__init__.py`. - `src/sdlc_hub/modules/jira/` — модуль jira: `schema`, `mirror`, `history`, `attachments`, `fetch`, `commands`, `refresh`, `projects`, `hooks` (смысл типов команд для очереди), `api`, `mcp`, `settings`, `importer`/`cli`. Входная точка — `MODULE` в `modules/jira/__init__.py`.
- `src/jira_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`. - `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`. Меток-исключений нет. - Граница: ядро не импортирует модули (кроме ленивого импорта в `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`. - Цепочка ядра: `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`); модуль 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`); модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`).
@ -24,16 +24,16 @@
## Команды ## Команды
- `uv run pytest` — весь набор тестов. Зелёным считается только весь набор. - `uv run pytest` — весь набор тестов. Зелёным считается только весь набор.
- `uv sync` — установка окружения. - `uv sync` — установка окружения.
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. - `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба.
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`, лента изменений — `/app/feed?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. - Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`, лента изменений — `/app/feed?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску.
- `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` (только при смене фикстур). - `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 jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [модуль] [--project <метка>]` — MCP модуля по stdio (по умолчанию `jira`; `jira-hub mcp wiki` — сервер `jira-hub-wiki`; с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены (клиентский и по агенту на модуль). - `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 jira-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). - `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`).
## Тесты ## Тесты
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются. - Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют. - SQLite в памяти или во временном `SDLC_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
- Соединение SQLite в тесте — через фикстуру `open_db` (`tests/conftest.py`) или фикстуру `conn` модуля, иначе с явным `close()`: `pyproject.toml` (`filterwarnings`) делает незакрытое соединение ошибкой набора. - Соединение SQLite в тесте — через фикстуру `open_db` (`tests/conftest.py`) или фикстуру `conn` модуля, иначе с явным `close()`: `pyproject.toml` (`filterwarnings`) делает незакрытое соединение ошибкой набора.
- Фронт: golden-эталоны доски — `web/src/modules/jira/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе. - Фронт: golden-эталоны доски — `web/src/modules/jira/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе.

View File

@ -1,6 +1,6 @@
.PHONY: web check .PHONY: web check
# сборка веб-приложения в src/jira_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос) # сборка веб-приложения в src/sdlc_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос)
web: web:
cd web && npm ci && npm run build cd web && npm ci && npm run build

142
README.md
View File

@ -1,4 +1,4 @@
# jira-hub # sdlc-hub
Хаб моста Jira для АРМ КА: зеркало задач проектов-меток Jira-проекта AURORA (AWPCA и др.) в SQLite, служебные заметки, очередь команд, Хаб моста Jira для АРМ КА: зеркало задач проектов-меток Jira-проекта AURORA (AWPCA и др.) в SQLite, служебные заметки, очередь команд,
HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только
@ -15,24 +15,24 @@ Python для хаба uv ставит свой (3.13, `.python-version`; сис
`pyproject.toml`): в SQLite 3.51.0–3.51.1 есть взаимная блокировка, от которой хаб зависал под параллельными запросами. `pyproject.toml`): в SQLite 3.51.0–3.51.1 есть взаимная блокировка, от которой хаб зависал под параллельными запросами.
Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`. Первый `uv sync` или `uv run` после смены сам пересоздаёт `.venv`.
Код: ядро — `src/jira_hub/core/`, модуль jira — `src/jira_hub/modules/jira/`, модуль wiki — `src/jira_hub/modules/wiki/`, веб — `web/src/` (модуль jira — `web/src/modules/jira/`); карта — `ARCHITECTURE.md`. Код: ядро — `src/sdlc_hub/core/`, модуль jira — `src/sdlc_hub/modules/jira/`, модуль wiki — `src/sdlc_hub/modules/wiki/`, веб — `web/src/` (модуль jira — `web/src/modules/jira/`); карта — `ARCHITECTURE.md`.
Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`): Данные лежат в `~/.local/share/sdlc-hub/` (каталог переопределяется переменной `SDLC_HUB_HOME`):
`hub.sqlite`, `backups/`, `release/<модуль>/`, `files/` (файлы вложений по sha256), `downloads/` (копии вложений для MCP) и `wiki/<метка>/` (Markdown-копия вики проекта); каталоги — 0700. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API. `hub.sqlite`, `backups/`, `release/<модуль>/`, `files/` (файлы вложений по sha256), `downloads/` (копии вложений для MCP) и `wiki/<метка>/` (Markdown-копия вики проекта); каталоги — 0700. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API.
## Первый запуск ## Первый запуск
```bash ```bash
uv run jira-hub init --config ~/projects/awp-ca/admin/jira-sync/config.example.json uv run sdlc-hub init --config ~/projects/awp-ca/admin/jira-sync/config.example.json
uv run jira-hub serve # 0.0.0.0:8790 uv run sdlc-hub serve # 0.0.0.0:8790
``` ```
`init` переносит в настройки хаба всё, что относится к проекту Jira (поля, типы, карту переходов, типы связей, доску, `init` переносит в настройки хаба всё, что относится к проекту Jira (поля, типы, карту переходов, типы связей, доску,
приоритеты), и создаёт токены: клиентский и по одному на агента каждого модуля (`jira`, `wiki`). Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`, приоритеты), и создаёт токены: клиентский и по одному на агента каждого модуля (`jira`, `wiki`). Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`,
`ca_bundle`) остаются у агента и в хаб не попадают. Повторный `init` отказывает ("уже инициализирован"); с `--force` `ca_bundle`) остаются у агента и в хаб не попадают. Повторный `init` отказывает ("уже инициализирован"); с `--force`
настройки переписываются. Токены можно показать позже: `uv run jira-hub tokens`. настройки переписываются. Токены можно показать позже: `uv run sdlc-hub tokens`.
Передать агенту jira нужно адрес хаба и токен агента jira (`uv run jira-hub tokens` → «агент jira»): через общую папку виртуалки положить в `agent.json` Передать агенту jira нужно адрес хаба и токен агента jira (`uv run sdlc-hub tokens` → «агент jira»): через общую папку виртуалки положить в `agent.json`
`"hub_url": "http://192.168.0.106:8790"` и `"hub_token": "<токен агента jira>"`. Клиентский токен остаётся на этой машине: `"hub_url": "http://192.168.0.106:8790"` и `"hub_token": "<токен агента jira>"`. Клиентский токен остаётся на этой машине:
его использует MCP (читает из базы сам). его использует MCP (читает из базы сам).
@ -41,7 +41,7 @@ uv run jira-hub serve # 0.0.0.0:8790
## Импорт пакета admin/jira-sync ## Импорт пакета admin/jira-sync
```bash ```bash
uv run jira-hub import ~/projects/awp-ca/admin/jira-sync uv run sdlc-hub import ~/projects/awp-ca/admin/jira-sync
``` ```
Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON: Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON:
@ -51,24 +51,24 @@ uv run jira-hub import ~/projects/awp-ca/admin/jira-sync
## MCP в Claude ## MCP в Claude
`jira-hub mcp [модуль]` запускает MCP-сервер модуля по stdio (по умолчанию `jira`); он ходит в API хаба клиентским токеном. Если хаб не запущен, инструменты `sdlc-hub mcp <модуль>` запускает MCP-сервер модуля по stdio (модуль обязателен: `jira` или `wiki`; без него или с неизвестным — код 2); серверы называются `sdlc-jira` и `sdlc-wiki`; он ходит в API хаба клиентским токеном. Если хаб не запущен, инструменты
отвечают «хаб не запущен: jira-hub serve». Регистрация вручную (файл `~/.claude.json` скрипты хаба не правят), отвечают «хаб не запущен: sdlc-hub serve». Регистрация вручную (файл `~/.claude.json` скрипты хаба не правят),
в user scope — MCP нужен и в `~/projects/jira-bridge`, и в `~/projects/awp-ca`, где ведут задачи AURORA: в user scope — MCP нужен и в `~/projects/jira-bridge`, и в `~/projects/awp-ca`, где ведут задачи AURORA:
```bash ```bash
claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/projects/jira-bridge/jira-hub jira-hub mcp claude mcp add -s user sdlc-jira -- uv run --project /Users/andrey.epifancev/projects/jira-bridge/sdlc-hub sdlc-hub mcp jira
``` ```
После перезапуска хаба или обновления кода MCP — `/mcp` → reconnect. После перезапуска хаба или обновления кода MCP — `/mcp` → reconnect.
MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp wiki`). Регистрация в `.mcp.json` репозитория проекта (рядом с `jira-hub`, с привязкой к проекту): MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki`). Регистрация в `.mcp.json` репозитория проекта (рядом с `sdlc-jira`, с привязкой к проекту):
```json ```json
{ {
"mcpServers": { "mcpServers": {
"jira-hub-wiki": { "sdlc-wiki": {
"command": "uv", "command": "uv",
"args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/jira-hub", "jira-hub", "mcp", "wiki", "--project", "AWPCA"] "args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub", "sdlc-hub", "mcp", "wiki", "--project", "AWPCA"]
} }
} }
} }
@ -81,7 +81,7 @@ MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp
(метка сравнивается точно, с учётом регистра): (метка сравнивается точно, с учётом регистра):
```json ```json
{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/jira-hub", "jira-hub", "mcp", "--project", "AWPCA"]}}} {"mcpServers": {"sdlc-jira": {"command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub", "sdlc-hub", "mcp", "jira", "--project", "AWPCA"]}}}
``` ```
Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`, Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`,
@ -117,7 +117,7 @@ MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp
В примере у `ensure_issue` нет `labels`: в привязанной сессии метку проекта дописывает хаб. Новый проект — `project_create`: В примере у `ensure_issue` нет `labels`: в привязанной сессии метку проекта дописывает хаб. Новый проект — `project_create`:
в ответе «проект <метка> «<название>» заведён, пакет обновления #N», затем указание положить в корень репозитория проекта в ответе «проект <метка> «<название>» заведён, пакет обновления #N», затем указание положить в корень репозитория проекта
`.mcp.json` (слить с имеющимся) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта) и сам фрагмент `.mcp.json` `.mcp.json` (слить с имеющимся) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта) и сам фрагмент `.mcp.json`
с командой `uv run --project <корень хаба> jira-hub mcp --project <метка>` (модуль по умолчанию `jira`; явно — `jira-hub mcp jira --project <метка>`). с командой `uv run --project <корень хаба> sdlc-hub mcp jira --project <метка>` (модуль указывается явно).
«Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`, «Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`,
загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла, загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла,
@ -134,16 +134,16 @@ MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp
Копию вики Eva хаб получает от `eva-agent` — отдельного агента на той же виртуалке (отдельный репозиторий `aep/eva-agent`, Копию вики Eva хаб получает от `eva-agent` — отдельного агента на той же виртуалке (отдельный репозиторий `aep/eva-agent`,
каталог `jira-bridge/eva-agent/`; Python 3.9, один файл `src/agent.py` + `run.py` — как jira-agent). Хаб в Eva не ходит и токен Eva не хранит. каталог `jira-bridge/eva-agent/`; Python 3.9, один файл `src/agent.py` + `run.py` — как jira-agent). Хаб в Eva не ходит и токен Eva не хранит.
1. `uv run jira-hub tokens` — взять токен агента `wiki` (у `jira` и `wiki` токены разные; чужой токен на маршрутах другого агента даёт 401). 1. `uv run sdlc-hub tokens` — взять токен агента `wiki` (у `jira` и `wiki` токены разные; чужой токен на маршрутах другого агента даёт 401).
2. На виртуалке отдельным каталогом положить eva-agent и `agent.json`: `hub_url` (`folder:<путь>` или http), `hub_token` — токен агента `wiki`, 2. На виртуалке отдельным каталогом положить eva-agent и `agent.json`: `hub_url` (`folder:<путь>` или http), `hub_token` — токен агента `wiki`,
`eva: {base_url, token}`, `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, `auto_update`. Токен Eva — только здесь. Первый запуск `eva: {base_url, token}`, `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, `auto_update`. Токен Eva — только здесь. Первый запуск
делает владелец руками (свой процесс и блокировка). делает владелец руками (свой процесс и блокировка).
3. Публикация версии для самообновления: `uv run jira-hub agent publish --module wiki ../eva-agent/src/agent.py` 3. Публикация версии для самообновления: `uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py`
(файл ложится в `release/wiki/`; `min_agent_version` модуля — настройка `wiki_min_agent_version`). (файл ложится в `release/wiki/`; `min_agent_version` модуля — настройка `wiki_min_agent_version`).
4. Корень вики проекта: `project_create` с `wiki_root` или MCP `root_set DOC-…` / форма на странице «Вики» (`PATCH /projects/{метка}`) — 4. Корень вики проекта: `project_create` с `wiki_root` или MCP `root_set DOC-…` / форма на странице «Вики» (`PATCH /projects/{метка}`) —
хаб ставит `wiki_sync`; дальше синхронизация раз в `wiki_auto_sync_hours` (по умолчанию 24, `0` — выключена), кнопка «Обновить» или MCP `sync`. хаб ставит `wiki_sync`; дальше синхронизация раз в `wiki_auto_sync_hours` (по умолчанию 24, `0` — выключена), кнопка «Обновить» или MCP `sync`.
Копия лежит в `~/.local/share/jira-hub/wiki/<метка>/` (Markdown, по каталогу на страницу). Настройки модуля: `wiki_pacing` (пауза агента между Копия лежит в `~/.local/share/sdlc-hub/wiki/<метка>/` (Markdown, по каталогу на страницу). Настройки модуля: `wiki_pacing` (пауза агента между
запросами к Eva, с), `wiki_auto_sync_hours`, `wiki_min_agent_version`. запросами к Eva, с), `wiki_auto_sync_hours`, `wiki_min_agent_version`.
## Транспорт через общую папку ## Транспорт через общую папку
@ -152,7 +152,7 @@ MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp
в хаб через общую папку UTM. Реле работает внутри `serve` и включается настройкой: в хаб через общую папку UTM. Реле работает внутри `serve` и включается настройкой:
```bash ```bash
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') TOKEN=$(uv run sdlc-hub tokens | sed -n 's/^клиентский токен: //p')
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
--data '{"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"}' http://127.0.0.1:8790/settings --data '{"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"}' http://127.0.0.1:8790/settings
``` ```
@ -176,12 +176,12 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j
Опубликовать новую версию: Опубликовать новую версию:
```bash ```bash
uv run jira-hub agent publish ../jira-agent/src/agent.py # модуль jira (по умолчанию) uv run sdlc-hub agent publish ../jira-agent/src/agent.py # модуль jira (по умолчанию)
uv run jira-hub agent publish --module wiki ../eva-agent/src/agent.py uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
``` ```
Версия берётся из строки `AGENT_VERSION = N` и должна быть больше опубликованной; файл и `manifest.json` ложатся в Версия берётся из строки `AGENT_VERSION = N` и должна быть больше опубликованной; файл и `manifest.json` ложатся в
`~/.local/share/jira-hub/release/<модуль>/` (0700). Перезапуск хаба не нужен: версия уходит агенту в ответе `claim` `~/.local/share/sdlc-hub/release/<модуль>/` (0700). Перезапуск хаба не нужен: версия уходит агенту в ответе `claim`
(`agent_release`), файл — по `GET /agent/release`. Агент ставит её в паузе между командами; через несколько минут (`agent_release`), файл — по `GET /agent/release`. Агент ставит её в паузе между командами; через несколько минут
`agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь, `agent_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь,
запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером. запускатель вернёт прежнюю — `agent_status` покажет «откат с v<N> …»; исправление публикуется следующим номером.
@ -228,25 +228,25 @@ uv run jira-hub agent publish --module wiki ../eva-agent/src/agent.py
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 |
```bash ```bash
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') TOKEN=$(uv run sdlc-hub tokens | sed -n 's/^клиентский токен: //p')
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=Спринт%202&limit=20" curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=Спринт%202&limit=20"
``` ```
## Веб-приложение ## Веб-приложение
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере; `/links` перенаправляют на доску. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
``` ```
cd ~/projects/jira-bridge/jira-hub cd ~/projects/jira-bridge/sdlc-hub
git pull git pull
launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub
make web make web
``` ```
Порядок важен для версии со схемой v7 (модуль wiki): сразу после `git pull` — kickstart, потом `make web`. До kickstart не Порядок важен для версии со схемой v7 (модуль wiki): сразу после `git pull` — kickstart, потом `make web`. До kickstart не
запускать MCP и CLI `jira-hub` (`tokens`, `mcp`, `import`): они открывают базу и переводят её в v7, а хаб на прежнем коде запускать MCP и CLI `sdlc-hub` (`tokens`, `mcp`, `import`): они открывают базу и переводят её в v7, а хаб на прежнем коде
с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` —
вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента. вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента.
@ -322,8 +322,8 @@ make web
## Пример сценария ## Пример сценария
1. `jira-hub serve` на Mac mini, агент запущен на виртуалке. 1. `sdlc-hub serve` на Mac mini, агент запущен на виртуалке.
2. В Claude: `refresh` с `full` загружает задачи в зеркало; `jira-hub import` переносит заметки из CSV. 2. В Claude: `refresh` с `full` загружает задачи в зеркало; `sdlc-hub import` переносит заметки из CSV.
3. `batch_create` ставит пакет; `batch_wait` ждёт результата; `issue` показывает итог. 3. `batch_create` ставит пакет; `batch_wait` ждёт результата; `issue` показывает итог.
## Эксплуатация ## Эксплуатация
@ -341,7 +341,76 @@ make web
перезапущен), хаб выдаёт её снова; `agent_status` показывает такие команды с числом выдач. После 3 выдач без перезапущен), хаб выдаёт её снова; `agent_status` показывает такие команды с числом выдач. После 3 выдач без
отчёта команда становится `failed` («агент не отчитался после 3 выдач»), очередь идёт дальше; повтор — отчёта команда становится `failed` («агент не отчитался после 3 выдач»), очередь идёт дальше; повтор —
`command_retry`. Отменить взятую команду: `POST /commands/{id}/cancel` с `{"force": true}`. `command_retry`. Отменить взятую команду: `POST /commands/{id}/cancel` с `{"force": true}`.
- Автозапуск `serve` через launchd: `~/Library/LaunchAgents/ru.aepif.jira-hub.plist` - Автозапуск `serve` через launchd: `~/Library/LaunchAgents/ru.aepif.sdlc-hub.plist`, лог `~/Library/Logs/sdlc-hub.log`
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>ru.aepif.sdlc-hub</string>
<key>ProgramArguments</key><array>
<string>/opt/homebrew/bin/uv</string><string>run</string><string>--project</string>
<string>/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub</string>
<string>sdlc-hub</string><string>serve</string>
</array>
<key>EnvironmentVariables</key><dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>PYTHONUNBUFFERED</key><string>1</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/Users/andrey.epifancev/Library/Logs/sdlc-hub.log</string>
<key>StandardErrorPath</key><string>/Users/andrey.epifancev/Library/Logs/sdlc-hub.log</string>
</dict></plist>
```
Загрузить: `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ru.aepif.sdlc-hub.plist`. Перезапуск: `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub`.
## Переключение с jira-hub
Одноразовое переключение живой установки (хаб назывался `jira-hub`) делает скрипт `scripts/cutover.sh` (обёртка над
`scripts/cutover.py`, stdlib). Выполняет владелец; без `--apply` скрипт только проверяет и печатает шаги.
1. Слить PR переименования; переименовать репозиторий в Gitea `aep/jira-hub` -> `aep/sdlc-hub`. Gitea не
перенаправляет git по SSH со старого имени: сразу сменить адрес `origin` (общий для checkout и всех worktree) —
`git -C ~/projects/jira-bridge/jira-hub remote set-url origin ssh://git@git.aepif.ru:2222/aep/sdlc-hub.git`.
2. Закрыть или не трогать сессии Claude с MCP хаба (после переключения -- `/mcp` reconnect).
3. `git -C ~/projects/jira-bridge/jira-hub pull --ff-only`, сразу `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh`
-- сухой прогон, проверить вывод (шаг 1 скрипта: чистый checkout на `main` = `main` в `aep/sdlc-hub`, доступен `aep/sdlc-hub`,
нет целевых каталогов, есть `uv`, `make`, `claude`, `launchctl`, `node`, `npm`; неотслеживаемая старая сборка
`src/jira_hub/` не мешает, на шаге 4 она удаляется; адрес нового `origin` берётся из прежнего с заменой имени
репозитория, протокол (ssh/https) сохраняется). Скрипт также предупреждает об открытых сессиях Claude со старым
MCP (`pgrep -fl 'jira-hub mcp'`): после шага 3 они заново создают `~/.local/share/jira-hub`. Запускать скрипт лучше
из обычного терминала вне checkout. Между pull и переключением работающий хаб
живёт на загруженном коде, а новые сессии Claude не поднимут MCP -- окно в несколько минут.
4. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh --apply`. Шаги скрипта, останавливается на первой ошибке:
2. остановить `ru.aepif.jira-hub`, дождаться закрытия порта 8790, переименовать его plist в `ru.aepif.jira-hub.plist.pre-sdlc`;
3. `~/.local/share/jira-hub` -> `~/.local/share/sdlc-hub`;
4. checkout `jira-bridge/jira-hub` -> `jira-bridge/sdlc-hub`, удалить неотслеживаемый `src/jira_hub`, `git worktree repair` (включая `.wt/*`), `origin` -> `aep/sdlc-hub`,
новый `.venv`, `uv sync`, `make web`;
5. поставить `ru.aepif.sdlc-hub` (plist выше), запустить, дождаться порта;
6. править `.mcp.json`, `.claude/settings*.json`, `CLAUDE.md`, `.claude/skills` в `~/projects/awp-ca`,
`~/projects/stocks_drrk` и в каждом `~/projects/*` с `jira-hub` в `.mcp.json` (например `sfm_foxpro`; копии рядом: `*.pre-sdlc`);
7. user scope: `claude mcp add -s user sdlc-jira -- uv run --project <checkout> sdlc-hub mcp jira`, затем `claude mcp remove -s user jira-hub`.
5. Просмотреть правки в awp-ca и stocks_drrk, проверить оставшиеся упоминания `jira-hub` из вывода скрипта, удалить копии `*.pre-sdlc`
(это не git-репозитории -- коммитить нечего). `/mcp` reconnect; `codegraph init` и `uv sync` в новом checkout и в каждом worktree `.wt/*`; после запуска -- лог реле и `agent_status`
(при «Operation not permitted» выдать python из нового `.venv` полный доступ к диску заново).
6. Проверить: `agent_status` через `sdlc-jira`, `status` через `sdlc-wiki`, веб `http://127.0.0.1:8790/app/` без повторного входа
(токен и раскладка доски переносятся из прежних ключей браузерного хранилища).
В уже существующем окружении (`.venv` старого checkout) после переезда пакета нужен `uv sync --reinstall-package sdlc-hub`
(скрипт пересоздаёт `.venv` сам).
### Откат
Если скрипт остановился или хаб не поднялся (скрипт печатает «Откат: …» со своими путями):
1. Остановить новый хаб: `launchctl bootout gui/$(id -u)/ru.aepif.sdlc-hub`; удалить `~/Library/LaunchAgents/ru.aepif.sdlc-hub.plist`.
2. Если `~/.local/share/jira-hub` появился заново (сессия Claude со старым MCP) -- удалить его перед `mv`. Вернуть каталоги: `mv ~/.local/share/sdlc-hub ~/.local/share/jira-hub`; `mv ~/projects/jira-bridge/sdlc-hub ~/projects/jira-bridge/jira-hub`,
затем `git -C ~/projects/jira-bridge/jira-hub worktree repair` (и пути всех `.wt/*`).
3. Вернуть `origin` на `aep/jira-hub` (`git remote set-url origin …`; если репозиторий в Gitea уже переименован -- адрес прежнего имени
перенаправляется, но лучше убедиться) и `git checkout` коммита `main` перед слиянием PR `sdlc-rename` (найти в `git log`); `uv sync` и при необходимости `make web`.
4. Вернуть прежний plist (скрипт оставил копию `ru.aepif.jira-hub.plist.pre-sdlc`: `mv` без суффикса; иначе создать заново по тексту ниже) и загрузить его (`launchctl bootstrap gui/$(id -u) …`):
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@ -349,20 +418,25 @@ make web
<plist version="1.0"><dict> <plist version="1.0"><dict>
<key>Label</key><string>ru.aepif.jira-hub</string> <key>Label</key><string>ru.aepif.jira-hub</string>
<key>ProgramArguments</key><array> <key>ProgramArguments</key><array>
<string>uv</string><string>run</string><string>--project</string> <string>/opt/homebrew/bin/uv</string><string>run</string><string>--project</string>
<string>/Users/andrey.epifancev/projects/jira-bridge/jira-hub</string> <string>/Users/andrey.epifancev/projects/jira-bridge/jira-hub</string>
<string>jira-hub</string><string>serve</string> <string>jira-hub</string><string>serve</string>
</array> </array>
<key>EnvironmentVariables</key><dict> <key>EnvironmentVariables</key><dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string> <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>PYTHONUNBUFFERED</key><string>1</string>
</dict> </dict>
<key>RunAtLoad</key><true/> <key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/> <key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/tmp/jira-hub.err.log</string> <key>StandardOutPath</key><string>/Users/andrey.epifancev/Library/Logs/jira-hub.log</string>
<key>StandardErrorPath</key><string>/Users/andrey.epifancev/Library/Logs/jira-hub.log</string>
</dict></plist> </dict></plist>
``` ```
Загрузить: `launchctl load ~/Library/LaunchAgents/ru.aepif.jira-hub.plist`. 5. Вернуть файлы проектов из копий: в каждом проекте-потребителе (awp-ca, stocks_drrk, sfm_foxpro и т. д.) каждый `*.pre-sdlc` переместить поверх оригинала
(`mv файл.pre-sdlc файл`).
6. Вернуть MCP: `claude mcp add -s user jira-hub -- uv run --project ~/projects/jira-bridge/jira-hub jira-hub mcp`, затем
`claude mcp remove -s user sdlc-jira`; `/mcp` reconnect.
## Тесты ## Тесты

View File

@ -0,0 +1,215 @@
# Переименование jira-hub → sdlc-hub — план реализации
> **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:** одно имя `sdlc-hub` в пакете, CLI, каталоге данных, MCP, вебе и документации хаба плюс скрипт, которым владелец переключает живую установку.
**Architecture:** механическое переименование пакета `src/jira_hub` → `src/sdlc_hub` и всех строк; MCP-серверы `sdlc-<модуль>` с обязательным модулем; защита от «код новый — каталог данных старый» в `core/paths.py` + `core/cli.py`; разовый перенос ключей `localStorage`; одноразовый скрипт `scripts/cutover.py` (stdlib, вне пакета) с обёрткой `scripts/cutover.sh`.
**Tech Stack:** Python 3.13 (uv, pytest), React 19 + TS + Vite (Vitest, Playwright), bash.
**Spec:** `openspec/changes/sdlc-rename/` (proposal.md, design.md, specs/) — issue jira-hub#97.
## Global Constraints
- Имя: пакет `sdlc_hub`, дистрибутив и CLI `sdlc-hub`, без псевдонима `jira-hub` (design, решение 2).
- Каталог данных `~/.local/share/sdlc-hub/`, переменная `SDLC_HUB_HOME`; прежнее имя `~/.local/share/jira-hub/` встречается в коде только в проверке прежнего каталога (Task 3) и в скрипте переключения (Task 5).
- MCP-серверы `sdlc-jira`, `sdlc-wiki`; `sdlc-hub mcp <модуль>`, модуль обязателен.
- Бренд веба — «SDLC Hub»; ключи `localStorage` `sdlc-hub-token`, `sdlc-hub-sidebar`, `sdlc-hub-legend`.
- Не менять: `docs/superpowers/plans/` (кроме этого плана), `openspec/changes/archive/`, `openspec/specs/` (их меняет архив change), протокол агента, схему базы, HTTP-маршруты, токены, ключи доски `graph-*`.
- Граница ядра (`tests/test_registry.py`) остаётся зелёной; новые имена в ядре — без названий модулей.
- Зелёный — только весь набор: `uv run pytest -q` и `cd web && npm run lint && npm test`.
- Язык комментариев, сообщений и коммитов — русский; коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Живой хаб (`:8790`, `~/.local/share/jira-hub`), launchd, `~/.claude.json`, `~/projects/awp-ca`, `~/projects/stocks_drrk` в задачах НЕ трогать: тесты — только во временных каталогах.
## Review Focus
1. Старый `jira-hub` в `.mcp.json` проекта и новый код: `sdlc-hub mcp` без модуля должен падать с понятной подсказкой, а не поднимать сервер jira — Task 2, `test_mcp_module_required`.
2. Новый код при живом каталоге под старым именем: команда не должна создать пустой `~/.local/share/sdlc-hub/` и работать с пустой базой — Task 3, `test_legacy_home_refused` проверяет, что каталог не создан.
3. Браузер с токеном под `jira-hub-token`: вход без повторного ввода токена; закрытое хранилище (исключение в `localStorage`) не ломает чтение — Task 4, `legacyStorage.test.ts`.
4. Сухой прогон скрипта не меняет ничего, в том числе в проектах-потребителях — Task 5, `test_dry_run_changes_nothing` сравнивает снимок дерева.
5. Повторный запуск скрипта после частичного переключения (данные уже перенесены): остановка на проверках шага 1 с понятным текстом, без второго переноса — Task 5, `test_apply_refuses_when_target_exists`.
---
### Task 1: Пакет, CLI и каталог данных под новым именем
Механическое переименование. Имена MCP-серверов (`FastMCP("jira-hub")`, `FastMCP("jira-hub-wiki")`, ключи в `mcp_json`, модуль MCP по умолчанию) в этой задаче НЕ меняются — это Task 2; строка команды `"jira-hub"` в `mcp_json` меняется на `"sdlc-hub"`.
**Files:**
- Move: `src/jira_hub/` → `src/sdlc_hub/` (`git mv`)
- Modify: `pyproject.toml` (`name = "sdlc-hub"`, `[project.scripts] sdlc-hub = "sdlc_hub.cli:main"`, `packages = ["src/sdlc_hub"]`), `uv.lock` (`uv lock`), `.gitignore` (`src/sdlc_hub/web/app/`), `Makefile` (комментарий), `web/vite.config.ts` (`outDir: "../src/sdlc_hub/web/app"`), `web/scripts/test-hub.sh` (`SDLC_HUB_HOME`, `uv run sdlc-hub`, защита `"$HOME/.local/share/sdlc-hub"*`), `web/scripts/golden-from-links.ts` — НЕ менять (читает старый коммит по старому пути)
- Modify: все `import`/`from jira_hub` в `src/` и `tests/`; `core/paths.py` (`SDLC_HUB_HOME`, по умолчанию `~/.local/share/sdlc-hub`); тексты `jira-hub` → `sdlc-hub` в `src/sdlc_hub/__init__.py` (docstring: «sdlc-hub — хаб SDLC: ядро (проекты, очередь команд, агенты, HTTP API, веб) и модули jira и wiki»), `core/cli.py` (`prog="sdlc-hub"`, `description="SDLC-хаб: ядро и модули"`, docstring), `core/api.py` (подсказка `make web` «в каталоге sdlc-hub», строка запуска `sdlc-hub: http://…`), `core/client.py`, `core/mcp.py` (`OFFLINE = "хаб не запущен: sdlc-hub serve"`, команда в `mcp_json`), модули `jira` и `wiki` (тексты подсказок и docstring с `jira-hub tokens`/`jira-hub serve`)
- Modify tests: `JIRA_HUB_HOME` → `SDLC_HUB_HOME` (`test_agents.py`, `test_cli.py`, `test_mcp.py`, `test_wiki_mcp.py`, `conftest.py` при наличии), пути `src/jira_hub` → `src/sdlc_hub` (`test_layout.py`, `test_registry.py`, `test_web_app.py` — `("sdlc_hub", "web", "app")`, `test_wiki_projects.py`)
- Test: `tests/test_layout.py`
**Interfaces:**
- Produces: пакет `sdlc_hub` (те же модули, что были в `jira_hub`), `sdlc_hub.core.paths.home() -> Path` читает `SDLC_HUB_HOME`, по умолчанию `Path.home() / ".local" / "share" / "sdlc-hub"`; CLI `sdlc-hub`.
- [ ] **Step 1: Тест каталога по умолчанию** — в `tests/test_layout.py`:
```python
def test_default_home(monkeypatch, tmp_path):
from sdlc_hub.core import paths
monkeypatch.delenv("SDLC_HUB_HOME", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub"
def test_env_home(monkeypatch, tmp_path):
from sdlc_hub.core import paths
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
assert paths.home() == tmp_path / "x"
```
- [ ] **Step 2:** `uv run pytest tests/test_layout.py -q` — FAIL (`ModuleNotFoundError: sdlc_hub`).
- [ ] **Step 3:** `git mv src/jira_hub src/sdlc_hub`; заменить `jira_hub` → `sdlc_hub` и `JIRA_HUB_HOME` → `SDLC_HUB_HOME` по `src/`, `tests/`, `web/vite.config.ts`, `.gitignore`, `Makefile`, `web/scripts/test-hub.sh`; правки `pyproject.toml`; `uv lock && uv sync`; тексты из списка Files.
- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS; `uv run sdlc-hub --help` печатает `usage: sdlc-hub`; `uv run jira-hub --help` — команды нет.
- [ ] **Step 5:** проверка остатков: `git grep -n -i "jira_hub\|JIRA_HUB" -- src tests web pyproject.toml Makefile .gitignore ':!web/scripts/golden-from-links.ts'` — пусто; `git grep -n "jira-hub" -- src tests` — только имена MCP-серверов (`FastMCP`, ключи `mcp_json`, их тесты и эталон `tests/fixtures/baseline/mcp_wiki_tools.expected.json`).
- [ ] **Step 6: Commit** — `git add -A && git commit -m "refactor(sdlc-rename): пакет sdlc_hub, CLI sdlc-hub, каталог данных sdlc-hub"`.
### Task 2: MCP-серверы `sdlc-<модуль>`, модуль обязателен
**Files:**
- Modify: `src/sdlc_hub/modules/jira/mcp.py` (`FastMCP("sdlc-jira")`), `src/sdlc_hub/modules/wiki/mcp.py` (`FastMCP("sdlc-wiki")`, docstring и описание `project_create`: «с сервером sdlc-wiki»), `src/sdlc_hub/core/mcp.py` (`mcp_json`, `run`), `src/sdlc_hub/core/cli.py` (аргумент `module` в `mcp`)
- Modify tests: `tests/test_cli.py` (`test_mcp_default_module` → `test_mcp_module_required`), `tests/test_mcp.py:500-520`, `tests/test_project_fields.py:152-157`, `tests/test_wiki_mcp.py:291,305`, эталон `tests/fixtures/baseline/mcp_wiki_tools.expected.json` (описание `project_create`)
**Interfaces:**
- Consumes: пакет `sdlc_hub` (Task 1).
- Produces: `core.mcp.mcp_json(label: str, module: str) -> dict` — `{"mcpServers": {f"sdlc-{module}": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "sdlc-hub", "mcp", module, "--project", label]}}}`, `module` без значения по умолчанию; `core.mcp.run(module_name: str | None, project: str | None = None) -> int`.
- [ ] **Step 1: Тесты** — в `tests/test_cli.py` заменить `test_mcp_default_module`:
```python
def test_mcp_module_required(monkeypatch, capsys):
from sdlc_hub.core import mcp as core_mcp
from sdlc_hub.modules.jira import mcp as jira_mcp
seen = []
monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(core_mcp.PROJECT))
assert cli.main(["mcp", "--project", "AWPCA"]) == 2
err = capsys.readouterr().err
assert "укажите модуль: sdlc-hub mcp <модуль>" in err and "jira, wiki" in err
assert seen == []
assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0
assert seen == ["AWPCA"]
```
В `test_project_fields.py::test_mcp_json_per_module`: `list(core_mcp.mcp_json("AWPCA", "jira")["mcpServers"]) == ["sdlc-jira"]`, `args[-4:] == ["mcp", "jira", "--project", "AWPCA"]`, `args[3] == "sdlc-hub"`; для `"wiki"` — `["sdlc-wiki"]`, `args[-4:] == ["mcp", "wiki", "--project", "AWPCA"]`. В `test_mcp.py` фрагмент `project_create` сравнивается с `core_mcp.mcp_json("STOCKS_DRRK", "jira")`, ключ `"sdlc-jira"`. В `test_wiki_mcp.py`: `m.mcp_server().name == "sdlc-wiki"`, `"sdlc-wiki" in created`. Имя сервера jira: `jira_mcp.mcp.name == "sdlc-jira"` (добавить в `test_mcp.py`).
- [ ] **Step 2:** `uv run pytest tests/test_cli.py tests/test_mcp.py tests/test_project_fields.py tests/test_wiki_mcp.py -q` — FAIL.
- [ ] **Step 3:** реализация: `cli` — `p.add_argument("module", nargs="?", default=None, help="имя модуля (jira, wiki); без модуля или неизвестный — код 2")`; `mcp.run(None, …)` печатает в stderr `укажите модуль: sdlc-hub mcp <модуль> [--project <метка>]; модули: <имена через ", ">` и возвращает 2; ветка неизвестного модуля прежняя. Вызов `mcp_json` в `core/mcp.py:186` уже передаёт `module`. Эталон baseline — заменить `jira-hub-wiki` на `sdlc-wiki` в описании.
- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS; `git grep -n "jira-hub" -- src tests` — пусто.
- [ ] **Step 5: Commit** — `feat(sdlc-rename): MCP-серверы sdlc-jira и sdlc-wiki, модуль в sdlc-hub mcp обязателен`.
### Task 3: Отказ при каталоге данных под прежним именем
**Files:**
- Modify: `src/sdlc_hub/core/paths.py`, `src/sdlc_hub/core/cli.py` (`main`, сразу после `parse_args`)
- Test: `tests/test_cli.py`
**Interfaces:**
- Consumes: `paths.home()` (Task 1).
- Produces: `paths.legacy_home() -> Path` (`Path.home() / ".local" / "share" / "jira-hub"`), `paths.home_problem() -> str | None` (текст отказа: `SDLC_HUB_HOME` не задан, `home()` не существует, `legacy_home()` существует), `paths.home_warning() -> str | None` (`SDLC_HUB_HOME` не задан, существуют оба).
- [ ] **Step 1: Тесты** в `tests/test_cli.py`:
```python
def _homes(monkeypatch, tmp_path, legacy=True, new=False):
monkeypatch.delenv("SDLC_HUB_HOME", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
share = tmp_path / ".local" / "share"
if legacy: (share / "jira-hub").mkdir(parents=True)
if new: (share / "sdlc-hub").mkdir(parents=True)
return share
@pytest.mark.parametrize("argv", [["serve"], ["tokens"], ["mcp", "jira"], ["agent", "publish", "x.py"]])
def test_legacy_home_refused(monkeypatch, tmp_path, capsys, argv):
share = _homes(monkeypatch, tmp_path)
assert cli.main(argv) == 2
err = capsys.readouterr().err
assert "~/.local/share/jira-hub" in err and "scripts/cutover.sh" in err
assert not (share / "sdlc-hub").exists()
assert sorted(p.name for p in (share / "jira-hub").iterdir()) == []
def test_both_homes_warn(monkeypatch, tmp_path, capsys):
_homes(monkeypatch, tmp_path, new=True)
cli.main(["tokens"])
assert "прежний каталог ~/.local/share/jira-hub можно удалить" in capsys.readouterr().err
def test_env_home_skips_legacy_check(monkeypatch, tmp_path, capsys):
_homes(monkeypatch, tmp_path)
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
assert cli.main(["tokens"]) == 0
```
- [ ] **Step 2:** `uv run pytest tests/test_cli.py -q -k "legacy or both_homes or env_home"` — FAIL.
- [ ] **Step 3:** реализация. Текст отказа (точно):
`каталог данных хаба лежит под прежним именем ~/.local/share/jira-hub; перенесите его: bash scripts/cutover.sh (или mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub при остановленном хабе)`.
Предупреждение: `есть и ~/.local/share/sdlc-hub, и ~/.local/share/jira-hub: работаю с новым, прежний каталог ~/.local/share/jira-hub можно удалить после проверки`. Проверка в `cli.main` до любой ветки команд; отказ — `print(..., file=sys.stderr)` и `return 2`.
- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS (прочие тесты задают `SDLC_HUB_HOME` и проверку не задевают).
- [ ] **Step 5: Commit** — `feat(sdlc-rename): отказ CLI при каталоге данных под прежним именем`.
### Task 4: Веб — бренд, ключи хранилища, подсказки
**Files:**
- Create: `web/src/api/legacyStorage.ts`, `web/src/api/legacyStorage.test.ts`
- Modify: `web/index.html` (`<title>SDLC Hub</title>`), `web/src/shell/Sidebar.tsx` (бренд «SDLC Hub», 2 места), `web/src/shell/Sidebar.test.tsx:204`, `web/src/auth/token.ts` (`TOKEN_KEY = "sdlc-hub-token"`, чтение через `readMigrated`, комментарий), `web/src/shell/sidebarPref.ts` (`sdlc-hub-sidebar`), `web/src/modules/jira/board/legendPref.ts` (`sdlc-hub-legend`), экран входа (подсказка `uv run sdlc-hub tokens`) и `web/src/auth/LoginScreen.test.tsx:21`, `web/e2e/screens.spec.ts:92` (`sdlc-hub-sidebar`), `web/vite.config.ts` (`SDLC_HUB_URL`), `web/package.json` и `web/package-lock.json` (`"name": "sdlc-hub-web"`)
**Interfaces:**
- Produces: `readMigrated(key: string): string | null` в `web/src/api/legacyStorage.ts` — ключ обязан начинаться с `sdlc-hub-`; если `localStorage.getItem(key)` не `null` — вернуть его; иначе прочитать `"jira-hub-" + key.slice("sdlc-hub-".length)`, при наличии — `setItem(key, v)`, `removeItem(old)`, вернуть `v`; любое исключение хранилища — `null`.
- [ ] **Step 1: Тесты** `web/src/api/legacyStorage.test.ts`:
- есть только `jira-hub-token = "abc"` → `readMigrated("sdlc-hub-token") === "abc"`, `localStorage.getItem("sdlc-hub-token") === "abc"`, `getItem("jira-hub-token") === null`;
- есть оба → возвращается новое, старое не трогается;
- нет ни одного → `null`, ничего не записано;
- `getItem` бросает (`vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("closed") })`) → `null`, без исключения.
- в `Sidebar.test.tsx` — `getByText("SDLC Hub")`; тест `sidebarPref`: при `jira-hub-sidebar = "collapsed"` `readSidebar() === true`.
- [ ] **Step 2:** `cd web && npx vitest run src/api/legacyStorage.test.ts src/shell` — FAIL.
- [ ] **Step 3:** реализация по Interfaces; `token.ts`, `sidebarPref.ts`, `legendPref.ts` читают через `readMigrated`, пишут и удаляют по новым ключам.
- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS; `git grep -n "jira-hub\|Хаб Jira\|JIRA_HUB" -- web ':!web/scripts/golden-from-links.ts'` — только `legacyStorage.ts` и его тест.
- [ ] **Step 5: Commit** — `feat(sdlc-rename): бренд SDLC Hub и ключи хранилища sdlc-hub-* с переносом`.
### Task 5: Скрипт переключения живой установки
**Files:**
- Create: `scripts/cutover.py` (stdlib, Python ≥ 3.9, вне пакета), `scripts/cutover.sh` (`exec python3 "$(dirname "$0")/cutover.py" "$@"`, исполняемый), `tests/test_cutover.py` (загрузка модуля через `importlib.util.spec_from_file_location`)
**Interfaces:**
- Produces (в `scripts/cutover.py`):
- `@dataclass Config`: `bridge: Path` (по умолчанию `~/projects/jira-bridge`), `data_root: Path` (`~/.local/share`), `launch_agents: Path` (`~/Library/LaunchAgents`), `logs: Path` (`~/Library/Logs`), `remote: str` (`https://git.aepif.ru/aep/sdlc-hub.git`), `projects: list[Path]` (`~/projects/awp-ca`, `~/projects/stocks_drrk`), `port: int` (8790); `Config.from_env()` — переопределения `CUTOVER_BRIDGE`, `CUTOVER_DATA_ROOT`, `CUTOVER_LAUNCH_AGENTS`, `CUTOVER_LOGS`, `CUTOVER_REMOTE`, `CUTOVER_PROJECTS` (через `:`), `CUTOVER_PORT`.
- `preflight(cfg: Config) -> list[str]` — проблемы шага 1 спеки: старый checkout `bridge/jira-hub` есть, чистый (`git status --porcelain` пуст), ветка `main`, `HEAD == origin/main` (после `git fetch`); `git ls-remote <remote>` успешен; `data_root/sdlc-hub` и `bridge/sdlc-hub` не существуют; `data_root/jira-hub` существует.
- `rewrite_mcp_json(path: Path, checkout: Path) -> bool` — ключи `jira-hub` → `sdlc-jira` с `args = ["run", "--project", str(checkout), "sdlc-hub", "mcp", "jira", *хвост --project]`, `jira-hub-wiki` → `sdlc-wiki` с `… "mcp", "wiki", …`; прочие серверы и порядок ключей сохраняются; `False`, если менять нечего.
- `rewrite_text(path: Path) -> list[str]` — замены по порядку: `mcp__jira-hub-wiki__`→`mcp__sdlc-wiki__`, `mcp__jira-hub__`→`mcp__sdlc-jira__`, `aep/jira-hub`→`aep/sdlc-hub`, `"jira-hub-wiki"`→`"sdlc-wiki"`, `"jira-hub"`→`"sdlc-jira"`, `` MCP `jira-hub` ``→`` MCP `sdlc-jira` ``, `MCP jira-hub`→`MCP sdlc-jira`; возвращает строки, где после замен осталось `jira-hub` (для сводки «проверить руками»).
- `backup(path: Path) -> Path` — копия `path` + `.pre-sdlc` до первой записи.
- `plist_text(checkout: Path, log: Path, uv: str) -> str` — как текущий `~/Library/LaunchAgents/ru.aepif.jira-hub.plist`, но `Label = ru.aepif.sdlc-hub`, `ProgramArguments = [uv, run, --project, checkout, sdlc-hub, serve]`, оба лога — `log`.
- `steps(cfg: Config) -> list[tuple[str, Callable[[], None]]]` — шаги 2–7 спеки с описаниями; `main(argv: list[str]) -> int` — без `--apply` печатает `preflight` и описания шагов, код 0 (или 1, если есть проблемы); с `--apply` — при проблемах код 1 без действий, иначе шаги по порядку с печатью, первая ошибка — код 1 и текст «остановлено на шаге N: …»; в конце сводка: `/mcp` reconnect, `codegraph init` в checkout и worktree, файлы `*.pre-sdlc`, строки из `rewrite_text` для ручной проверки.
- Файлы проектов для правки: `<project>/.mcp.json` (`rewrite_mcp_json`), `<project>/.claude/settings*.json`, `<project>/CLAUDE.md`, `<project>/.claude/skills/**/*` текстовые (`rewrite_text`).
- Шаг 5 ждёт TCP-подключения к `127.0.0.1:<port>` до 30 с. Шаг 7: `claude mcp remove -s user jira-hub`, затем `claude mcp add -s user sdlc-jira -- uv run --project <checkout> sdlc-hub mcp jira`.
- [ ] **Step 1: Тесты** `tests/test_cutover.py` (всё во `tmp_path`, `HOME` подменён, `launchctl`/`claude`/`make`/`uv` не запускаются):
- `test_rewrite_mcp_json` — вход: копия формы `~/projects/awp-ca/.mcp.json` (серверы `jira-hub` и `jira-hub-wiki` с `--project AWPCA`) плюс посторонний сервер `other`; выход: ключи `["sdlc-jira", "sdlc-wiki", "other"]` в этом порядке, `args` как в Interfaces, `other` без изменений; повторный вызов → `False`.
- `test_rewrite_text_settings` — `{"enabledMcpjsonServers": ["jira-hub", "jira-hub-wiki"], "permissions": {"allow": ["mcp__jira-hub__issue", "mcp__jira-hub-wiki__page"]}}` → `["sdlc-jira", "sdlc-wiki"]`, `["mcp__sdlc-jira__issue", "mcp__sdlc-wiki__page"]`, остатков нет.
- `test_rewrite_text_reports_leftovers` — строка «Мост Jira (aep/jira-hub, aep/jira-agent) … через MCP `jira-hub` (user scope)» → «aep/sdlc-hub», «MCP `sdlc-jira`»; строка «каталог jira-hub» попадает в остатки.
- `test_plist_text` — `plistlib.loads(plist_text(...).encode())`: `Label == "ru.aepif.sdlc-hub"`, `ProgramArguments[-2:] == ["sdlc-hub", "serve"]`, `StandardOutPath == StandardErrorPath == str(log)`, `KeepAlive is True`.
- `test_dry_run_changes_nothing` — подготовить `bridge/jira-hub` (git-репозиторий с `origin` на локальный bare-репозиторий, `main` = `origin/main`), `data_root/jira-hub/hub.sqlite`, проект с `.mcp.json`; `CUTOVER_REMOTE` — путь к bare-репозиторию; снимок `{путь: (mtime_ns, size)}` всего `tmp_path` до и после `main([])` совпадает, код 0.
- `test_apply_refuses_without_remote` — `CUTOVER_REMOTE` на несуществующий путь → `main(["--apply"]) == 1`, в выводе «aep/sdlc-hub», снимок не изменился.
- `test_apply_refuses_when_target_exists` — `data_root/sdlc-hub` уже есть → `main(["--apply"]) == 1`, текст про существующий каталог, снимок не изменился.
- `test_dirty_checkout_refused` — незакоммиченный файл в `bridge/jira-hub` → проблема в `preflight`.
- [ ] **Step 2:** `uv run pytest tests/test_cutover.py -q` — FAIL.
- [ ] **Step 3:** реализация по Interfaces. Внешние команды — через `subprocess.run(..., check=True)` в шагах; в сухом прогоне и `preflight` — только чтение (`git status`, `git rev-parse`, `git fetch`, `git ls-remote`).
- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS; `bash scripts/cutover.sh` с `CUTOVER_*` на временный каталог печатает шаги.
- [ ] **Step 5: Commit** — `feat(sdlc-rename): скрипт переключения живой установки scripts/cutover.sh`.
### Task 6: Документация
**Files:**
- Modify: `README.md`, `CLAUDE.md`, `ARCHITECTURE.md`
- [ ] **Step 1:** README: заголовок `# sdlc-hub`, все команды `uv run sdlc-hub …`, каталог `~/.local/share/sdlc-hub/` и `SDLC_HUB_HOME`, MCP — `sdlc-hub mcp jira|wiki`, серверы `sdlc-jira`/`sdlc-wiki`, регистрация user scope `claude mcp add -s user sdlc-jira -- uv run --project /Users/andrey.epifancev/projects/jira-bridge/sdlc-hub sdlc-hub mcp jira`, пути `~/projects/jira-bridge/sdlc-hub`, launchd `ru.aepif.sdlc-hub` с текстом plist из `plist_text` и логом `~/Library/Logs/sdlc-hub.log`, `kickstart … ru.aepif.sdlc-hub`. Новый раздел «Переключение с jira-hub» — шаги из `design.md` («Переключение (для владельца)») и откат: остановить `ru.aepif.sdlc-hub` (`launchctl bootout gui/$(id -u)/ru.aepif.sdlc-hub`), `mv` каталогов данных и checkout обратно, `git worktree repair`, вернуть `origin`, `git checkout` коммита до переименования, plist `ru.aepif.jira-hub` (прежний текст — в этом разделе), вернуть файлы `*.pre-sdlc` в проектах, `claude mcp` — обратно.
- [ ] **Step 2:** CLAUDE.md: заголовок `# sdlc-hub`, «Что это» — SDLC-хаб с модулями jira и wiki, пути `src/sdlc_hub/…`, `~/.local/share/sdlc-hub/` (`SDLC_HUB_HOME`), команды `uv run sdlc-hub …`, MCP `sdlc-hub mcp <модуль>` (обязателен; серверы `sdlc-jira`, `sdlc-wiki`). ARCHITECTURE.md — те же имена.
- [ ] **Step 3:** проверка: `git grep -n -i "jira-hub\|jira_hub\|JIRA_HUB\|Хаб Jira" -- . ':!docs/superpowers/plans' ':!openspec' ':!web/scripts/golden-from-links.ts'` — только: `src/sdlc_hub/core/paths.py` (прежний каталог), `tests/test_cli.py`, `scripts/cutover.py`, `tests/test_cutover.py`, `web/src/api/legacyStorage*.ts`, раздел README «Переключение с jira-hub». `uv run pytest -q` и `cd web && npm run lint && npm test` — PASS.
- [ ] **Step 4: Commit** — `docs(sdlc-rename): README, CLAUDE.md, ARCHITECTURE.md под sdlc-hub`.
Вне ветки хаба (делает контроллер сессии после слияния PR, bounded): мелкие PR в jira-agent и eva-agent — README, CLAUDE.md, ARCHITECTURE.md, `src/agent.example.json`, у eva-agent `scripts/e2e-hub.sh` (`SDLC_HUB_HOME`, `sdlc-hub`, путь хаба); `src/agent.py` и `src/run.py` не трогать (design, решение 10).

View File

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

View File

@ -0,0 +1,116 @@
## Context
`main` bb54d0a: схема v7, модули `jira` и `wiki`, агенты jira v14 и wiki v1. Имя `jira-hub` живёт в:
- **коде:** пакет `src/jira_hub/` (≈30 файлов импортируют `jira_hub`), `pyproject.toml` (дистрибутив и скрипт
`jira-hub`), `core/paths.py` (`JIRA_HUB_HOME`, `~/.local/share/jira-hub`), `core/cli.py` (`prog`, модуль MCP по
умолчанию `jira`), `core/mcp.py` (`mcp_json`: серверы `jira-hub`/`jira-hub-<модуль>`), `FastMCP("jira-hub")` и
`FastMCP("jira-hub-wiki")`, подсказки «jira-hub serve», `api.py` (текст «make web в каталоге jira-hub», строка запуска);
- **тестах:** `JIRA_HUB_HOME` в фикстурах, эталон `tests/fixtures/baseline/mcp_wiki_tools.expected.json`, проверки имён
серверов и фрагментов `.mcp.json`, пути `src/jira_hub` в `test_layout`, `test_registry`, `test_web_app`,
`test_wiki_projects`;
- **вебе:** `<title>Хаб Jira</title>`, бренд в `shell/Sidebar.tsx`, ключи `jira-hub-token|sidebar|legend`,
`JIRA_HUB_URL` в `vite.config.ts`, `web/scripts/test-hub.sh`, `package.json` (`jira-hub-web`), подсказка входа;
- **документации:** README (launchd, MCP, выкатка), CLAUDE.md, ARCHITECTURE.md, Makefile, `openspec/specs/` (18
требований в 7 спеках);
- **проектах-потребителях** (не git): `awp-ca` — `.mcp.json`, `enabledMcpjsonServers` в `.claude/settings.local.json`,
`CLAUDE.md`, скилл `building-customer-error-report` (`mcp__jira-hub__*`); `stocks_drrk` — `.mcp.json`,
`enabledMcpjsonServers`;
- **живой установке:** launchd `ru.aepif.jira-hub` (`uv run --project …/jira-bridge/jira-hub jira-hub serve`, лог
`~/Library/Logs/jira-hub.log`), данные `~/.local/share/jira-hub/`, `.mcp.json` в `~/projects/awp-ca` и
`~/projects/stocks_drrk` (серверы `jira-hub`, `jira-hub-wiki`), user scope MCP `jira-hub`, репозиторий Gitea
`aep/jira-hub`, checkout `~/projects/jira-bridge/jira-hub` с worktree в `.wt/`;
- **соседях:** jira-agent и eva-agent упоминают `jira-hub` только в документации, `agent.example.json` и e2e-скрипте
eva-agent (`JIRA_HUB_HOME`, `jira-hub` CLI). Протокол агента имени хаба не содержит: агенты ходят через общую папку.
## Goals / Non-Goals
**Goals:** одно имя `sdlc-hub` в коде, CLI, данных, MCP, вебе и документации; живой хаб переключается одним скриптом
владельца с сухим прогоном; новый код не может молча работать с пустым каталогом данных рядом с живым.
**Non-Goals и Anti-Goals:** см. proposal.md.
## Decisions
1. **Имя — `sdlc-hub`** (решение владельца 23.10.2026). Бренд в вебе — «SDLC Hub».
2. **Псевдонима `jira-hub` нет.** Старые записи MCP и launchd после переключения должны падать явно, а не работать
«почти». Переключение делает скрипт сразу для всех известных потребителей.
3. **MCP-серверы `sdlc-<модуль>`**, модуль в `sdlc-hub mcp` обязателен. Модуль по умолчанию был нужен только ради
неизменных записей `.mcp.json` в hub-modules; при переименовании записи меняются всё равно. Имена инструментов в
Claude станут `mcp__sdlc-jira__*` и `mcp__sdlc-wiki__*`.
4. **Каталог данных не переносится кодом.** Хаб, MCP-серверы открытых сессий Claude и CLI открывают одну базу; перенос
«на лету» при старте любого из них рискует WAL и гонкой. Код только отказывает с подсказкой (требование «Каталог
данных прежнего имени»), перенос — шаг скрипта при остановленном хабе.
5. **Скрипт переключения в репозитории** (`scripts/cutover.sh` — обёртка над `scripts/cutover.py` на stdlib, вне пакета
хаба), сухой прогон по умолчанию. Шагов много, они касаются
launchd, двух чужих репозиториев и user scope Claude; руками их легко сделать не в том порядке (I-2 wiki-module).
Скрипт одноразовый, после переключения остаётся в истории как образец; тесты — на сухой прогон и проверки шага 1
(во временном `HOME`), без launchd.
6. **Переименование репозитория в Gitea — руками владельца** в интерфейсе (Settings → Repository name). Перенаправления
по SSH Gitea не даёт (проверено 24.10.2026: «Cannot find repository: aep/jira-hub»), поэтому владелец сразу меняет
`origin` на новый адрес; скрипт сверяет `HEAD` с `main` нового репозитория и принимает уже сменённый `origin`.
7. **Checkout переезжает в `jira-bridge/sdlc-hub`**, workspace `jira-bridge` остаётся: от его пути зависят каталог
памяти Claude, workspace `CLAUDE.md`, путь общей папки реле (`…/utm/rshb/jira-bridge`) и настройки агентов на
виртуалке. Переименование workspace — отдельно, если понадобится.
8. **Ключи `localStorage` — с разовым переносом** из `jira-hub-*`: браузер владельца не должен снова просить токен и
терять свёрнутость панели и легенды. Ключи доски `graph-*` имени хаба не содержат и не меняются.
9. **История не переписывается**: `docs/superpowers/plans/`, `openspec/changes/archive/` остаются со старыми именами.
Раздел `Purpose` спек `hub-api`, `mcp`, `web-app` (дельтами не меняется) правится в PR архива.
10. **Агенты без релиза.** `src/agent.py` и `src/run.py` jira-agent и eva-agent не трогаются (строки документации — со
следующим релизом); README, CLAUDE.md, ARCHITECTURE.md, `agent.example.json` и `scripts/e2e-hub.sh` eva-agent —
мелкими PR в своих репозиториях (bounded, без change).
11. **Порядок с #98 (веб под модули).** Переименование сливается первым; ветка #98 перебазируется на него. Бренд и
ключи хранилища меняет этот change, устройство боковой панели — #98.
## Переключение (для владельца)
1. Слить PR этого change; переименовать репозиторий в Gitea `aep/jira-hub` → `aep/sdlc-hub`.
2. Закрыть или не трогать сессии Claude с MCP хаба (после переключения — `/mcp` reconnect).
3. `git -C ~/projects/jira-bridge/jira-hub pull --ff-only` и сразу `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh`
— сухой прогон, проверить вывод. Между pull и переключением работающий хаб живёт на загруженном коде, а новые сессии
Claude не поднимут MCP — окно в несколько минут.
4. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh --apply`.
5. Просмотреть правки в awp-ca и stocks_drrk (`.mcp.json`, `.claude/`, `CLAUDE.md` awp-ca; копии `*.pre-sdlc` рядом) и
удалить копии. Это не git-репозитории — коммитить нечего.
6. Проверить: `agent_status` через `sdlc-jira`, `status` через `sdlc-wiki`, веб `http://127.0.0.1:8790/app/` без
повторного входа.
Откат: остановить `ru.aepif.sdlc-hub`, перенести каталоги обратно, вернуть plist `ru.aepif.jira-hub` из README
предыдущей версии, `git checkout` прежнего коммита, вернуть записи MCP — порядок в README.
## Risks / Trade-offs
- **Сессии Claude с открытым MCP** держат старый путь к пакету: после переноса checkout их серверы падают до
reconnect. Принято: переключение — разовое событие владельца.
- **codegraph-индексы** в `.codegraph/` checkout и worktree содержат абсолютные пути: после переноса — `codegraph init`
заново (шаг в сводке скрипта).
- **Гонка с #98**: массовый перенос `src/jira_hub` → `src/sdlc_hub` конфликтует с любой веткой, трогающей Python;
#98 — только `web/`, поэтому конфликт ограничен брендом и ключами хранилища (решение 11).
## Decisions made without discussion
Владелец 24.10.2026 ответил «делай» на вопросы brainstorming — все решения ниже приняты агентом.
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 24.10.2026 | Бренд «SDLC Hub» | имя из решения владельца, латиница как в имени репозитория | правка строки в вебе |
| 2 | 24.10.2026 | Без псевдонима `jira-hub` | явный отказ лучше тихой работы старых записей | вернуть точку входа в `pyproject.toml` |
| 3 | 24.10.2026 | MCP `sdlc-jira`/`sdlc-wiki`, модуль обязателен | симметрия модулей, ядро без «jira по умолчанию» | переименовать серверы ещё раз во всех `.mcp.json` |
| 4 | 24.10.2026 | Каталог данных переносит скрипт, код только отказывает | гонка открытых баз при переносе на лету | ручной `mv` при остановленном хабе |
| 5 | 24.10.2026 | Скрипт `scripts/cutover.sh` с сухим прогоном | много шагов в чужих конфигурациях, порядок важен | шаги руками по README |
| 6 | 24.10.2026 | Gitea переименовывает владелец | действие на сервере, вне репозитория | — |
| 7 | 24.10.2026 | Workspace `jira-bridge` не переименовывается | от пути зависят память Claude, реле, агенты | отдельный change позже |
| 8 | 24.10.2026 | Перенос ключей `localStorage` | не просить токен заново | повторный вход в веб |
| 9 | 24.10.2026 | История (планы, архив) не меняется | это запись прошлого | — |
| 10 | 24.10.2026 | Агенты без релиза, документация — мелкими PR | протокол не меняется | строки документации в `agent.py` со старым именем до следующего релиза |
| 11 | 24.10.2026 | Сначала переименование, потом #98 | #98 перебазируется на новые пути | перебазирование веток |
| 12 | 24.10.2026 | R-1: трейлер коммитов `Co-Authored-By: Claude Sonnet 5.5` вместо `Opus 5.5` из плана | указание системы сессии новее плана | правка трейлера |
| 13 | 24.10.2026 | R-2: правки web/playwright.config.ts, web/scripts/seed-hub.py и JIRA_HUB_URL→SDLC_HUB_URL (vite.config.ts) сделаны уже в Task 1 | без них e2e, test-hub.sh и seed-скрипт ломаются между задачами | откат трёх файлов |
| 14 | 24.10.2026 | R-3: autouse-фикстура `_isolated_home` в tests/conftest.py подменяет HOME временным каталогом | реальный ~/.local/share/jira-hub на машине владельца иначе влиял бы на набор тестов | удаление фикстуры |
| 15 | 24.10.2026 | R-4: ключи `commentsPref` → `sdlc-hub-comments-hidden` (localStorage, с миграцией через `readMigrated`) и `IssueFilters` → `sdlc-hub-issue-filters` (sessionStorage, без миграции) | остатки `jira-hub-` в вебе нарушали проверку «только legacyStorage»; настройка комментариев переживает сессии, фильтры живут в рамках вкладки | однократный сброс настройки/фильтров, откат двух ключей |
| 16 | 24.10.2026 | R-5: комментарий в `RefreshProvider.tsx` «jira-hub#20» → «hub#20» | это номер issue, а не имя пакета | правка комментария |
| 17 | 24.10.2026 | R-6: HEAD == origin/main проверяется через `git ls-remote origin refs/heads/main`, а не `git fetch` | fetch пишет FETCH_HEAD и ломает «сухой прогон ничего не меняет»; ls-remote сравнивает с реальной вершиной сервера | без сети проверка даёт ошибку (безопасная сторона) |
| 18 | 24.10.2026 | R-7: детали apply в `scripts/cutover.py`, не заданные спекой: `launchctl bootout` старого сервиса с check=False; шаг 4 — удалить `.venv`, `uv sync`, `git worktree repair` по `.wt/*`, `remote set-url`, `make web`; файл проекта правится только если содержит `jira-hub`; после остановки ждать закрытия порта до 10 с; plist старого сервиса не удаляется, а переименовывается в `*.pre-sdlc` после закрытия порта (откат — `mv` обратно) | не загруженный сервис — не ошибка; порядок следует из зависимостей путей | правка порядка шагов внутри скрипта до живого запуска |
| 19 | 24.10.2026 | R-8: в откате README нет хеша коммита («коммит main перед слиянием PR sdlc-rename, см. git log»); текст прежнего plist взят с живой установки (только чтение), а не из старого README | хеш до слияния неизвестен; старый README устарел (нет PYTHONUNBUFFERED и /opt/homebrew/bin/uv) | уточнение текста отката |
| 20 | 24.10.2026 | R-9: потребители моста — список по умолчанию (awp-ca, stocks_drrk) плюс автообнаружение `~/projects/*/.mcp.json` с `jira-hub` (`CUTOVER_PROJECTS_ROOT`) | в `sfm_foxpro` обнаружен третий потребитель с `jira-hub --project sfm_kedr`; фиксированный список его бы пропустил | потребитель вне `~/projects` правится руками или через `CUTOVER_PROJECTS` |
| 21 | 24.10.2026 | R-10: скрипт переключения заменяет также `aep/jira-hub` → `aep/sdlc-hub` в CLAUDE.md и скиллах проектов-потребителей (шире п.6 спеки: «имена серверов и инструментов») | ссылки на репозиторий в этих файлах иначе остаются битыми после переименования в Gitea | остатки видны в сводке «проверить руками»; откат — копии `*.pre-sdlc` |

View File

@ -0,0 +1,42 @@
## Why
Хаб перестал быть только мостом Jira: с hub-modules (#87) он состоит из ядра и модулей, с wiki-module (#91) — второй
модуль (EVA Wiki) со своим агентом и своим MCP. Имя `jira-hub` теперь вводит в заблуждение: в вебе «Хаб Jira», MCP вики
называется `jira-hub-wiki`, данные вики лежат в `~/.local/share/jira-hub/`. Решение владельца 23.10.2026 — хаб
становится **sdlc-hub**; переименование было отложено до готовности модулей. 24.10.2026 владелец решил делать его сейчас,
вместе с пересборкой веба под модули (jira-hub#98), «раз мы причёсываем визуал».
## What Changes
- **Пакет и CLI.** Пакет `jira_hub` → `sdlc_hub`, дистрибутив `jira-hub` → `sdlc-hub`, команда `jira-hub` → `sdlc-hub`.
Псевдонима `jira-hub` нет.
- **MCP по модулю без модуля по умолчанию.** `sdlc-hub mcp <модуль> [--project <метка>]`; модуль обязателен. Серверы
называются `sdlc-<модуль>`: `sdlc-jira`, `sdlc-wiki`. Фрагмент `.mcp.json` из `project_create` — с новыми именами.
- **Каталог данных.** `~/.local/share/sdlc-hub/`, переменная `SDLC_HUB_HOME`. Если нового каталога нет, а прежний
`~/.local/share/jira-hub/` есть, любая команда хаба, открывающая базу, отказывает с текстом, как перенести каталог;
сама ничего не переносит.
- **Веб.** Бренд «SDLC Hub» (заголовок вкладки, боковая и верхняя панели), ключи `localStorage` `sdlc-hub-*` с разовым
переносом значений из `jira-hub-*`, подсказка входа `uv run sdlc-hub tokens`, `npm run dev` — `SDLC_HUB_URL`.
- **Переключение живого хаба.** Скрипт `scripts/cutover.sh` (сухой прогон по умолчанию, `--apply` — выполнить) для
владельца: останавливает launchd `ru.aepif.jira-hub`, переносит каталог данных, ставит `ru.aepif.sdlc-hub` с логом
`~/Library/Logs/sdlc-hub.log`, переписывает записи MCP в `.mcp.json` известных проектов и в user scope, переносит
checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub` и чинит worktree. Репозиторий в Gitea `aep/jira-hub` →
`aep/sdlc-hub` владелец переименовывает в интерфейсе Gitea, скрипт затем меняет `origin`.
- **Документация.** README, CLAUDE.md, ARCHITECTURE.md, текущие спеки (`openspec/specs/`) — на новые имена; workspace
`CLAUDE.md` и упоминания в jira-agent/eva-agent (README, CLAUDE.md, ARCHITECTURE.md, `agent.example.json`, e2e-скрипт
eva-agent) — отдельными мелкими PR в своих репозиториях.
## Non-Goals
- Пересборка веба под модули — jira-hub#98, отдельный change.
- Переименование workspace `jira-bridge`, общей папки реле (`relay_dir`), репозиториев агентов, launchd агентов на
виртуалке.
- Правка `src/agent.py` и `src/run.py` агентов: строки документации в них меняются со следующим релизом агента.
- Правка истории: `docs/superpowers/plans/`, `openspec/changes/archive/`.
- Изменения протокола агента, HTTP API (кроме текстов), схемы базы, токенов.
## Anti-Goals
- Живой хаб не должен оказаться в состоянии «код новый, данные/launchd старые» без явной ошибки: молчаливо создать пустой
`~/.local/share/sdlc-hub/` рядом с живыми данными — провал, даже если тесты зелёные.
- Агенты на виртуалке не требуют ни перенастройки, ни нового релиза.

View File

@ -0,0 +1,41 @@
## MODIFIED Requirements
### Requirement: Настройки
Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`,
`transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой
правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`;
относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию
`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением
по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование
«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме
передачи `board_id` агенту как запасной доски для команд без доски в параметрах (поставленных до миграции);
`GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через
`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json`
командой `sdlc-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект
`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига
`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы
один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда).
#### Scenario: Правка настроек поднимает версию
- **WHEN** через API изменён `pacing`
- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию
#### Scenario: Конфиг без ключей ошибки
- **WHEN** `sdlc-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug`
- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422
#### Scenario: Неверный relay_dir
- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"`
- **THEN** 422, значение не записано, `settings_version` не изменился
#### Scenario: Закрытые статусы по умолчанию
- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses`
- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт
#### Scenario: Запись базовой JQL
- **WHEN** `PUT /settings {base_jql: "project = AURORA"}`
- **THEN** 422 «базовая JQL собирается из проектов»; `settings_version` не изменился
#### Scenario: Создание проекта не трогает версию настроек
- **WHEN** создан проект `STOCKS_DRRK`
- **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками

View File

@ -0,0 +1,240 @@
## MODIFIED Requirements
### Requirement: Процесс и доступ
`sdlc-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
`~/.local/share/sdlc-hub/` (переопределяется `SDLC_HUB_HOME`). Все маршруты API SHALL требовать
`Authorization: Bearer <token>`: токен агента модуля открывает только `/agent/*` (маршруты ядра — от имени этого агента,
маршруты модуля со scope `agent` — только агенту своего модуля, см. `modules`, «Агенты модулей»), клиентский — всё
остальное. `sdlc-hub tokens` SHALL печатать клиентский токен и токены агентов с именами модулей. Исключение —
раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без
данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или
`Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки
дополнительно отвечает 502. Методы, кроме GET, POST, PUT и PATCH, отвечают 501 стандартной страницей `http.server`, не
JSON.
#### Scenario: Чужой токен
- **WHEN** токеном агента `jira` вызван `GET /issues`
- **THEN** 401
#### Scenario: Неподдерживаемый метод
- **WHEN** `DELETE /issues/AURORA-1`
- **THEN** 501, тело не JSON; база не меняется
#### Scenario: Страница без токена
- **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть
- **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет
#### Scenario: Токены после миграции
- **WHEN** база v6 с агентским токеном T мигрировала в v7, запущен `sdlc-hub tokens`
- **THEN** вывод содержит клиентский токен, `jira: T` и новый токен `wiki`
### Requirement: Реле через общую папку
`sdlc-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле
бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если
каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует —
записать одно предупреждение в лог и проверять снова.
Обмен:
- запрос агента — файл `req/<id>.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC
(ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать;
- реле SHALL брать запрос переименованием в `work/<id>.json` и обрабатывать каждый взятый запрос в отдельном потоке;
- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до
`deadline` осталось не меньше 35 с, иначе выбрасываться без ответа;
- остальные запросы реле SHALL пересылать в свой HTTP на `<host>:<port>` процесса `serve` (`127.0.0.1`, если `serve`
слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ
`resp/<id>.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и
переименование; затем удалять `work/<id>.json`;
- если соединение со своим HTTP не удалось или ответ нарушает протокол HTTP, ответ SHALL иметь `status = 502` и текст
ошибки в `body`;
- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог.
При первой подготовке каждой папки после старта реле (в том числе новой папки после смены `relay_dir`) реле SHALL
удалить файлы из `work/` с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL
только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту
реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку
старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay <METHOD> <path> <status>
<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются.
#### Scenario: Запрос через папку
- **WHEN** в `req/` появился `a1.json` с `GET /agent/settings`, агентским токеном и `deadline` через 60 с
- **THEN** в `resp/` появляется `a1.json` со `status = 200` и телом настроек; `work/a1.json` удалён
#### Scenario: Чужой токен через папку
- **WHEN** в файле запроса `GET /issues` с агентским токеном
- **THEN** ответ со `status = 401`, как по HTTP
#### Scenario: Просроченный claim
- **WHEN** реле взяло `POST /agent/claim`, до `deadline` которого осталось 20 с
- **THEN** запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась `queued`
#### Scenario: Долгий claim не задерживает другие запросы
- **WHEN** реле пересылает `claim`, который ждёт команду 30 с, и в это время в `req/` появляется `GET /agent/settings`
- **THEN** ответ на `settings` пишется, не дожидаясь `claim`
#### Scenario: Папка не подключена
- **WHEN** `relay_dir` задан, но каталога нет; через минуту каталог появился
- **THEN** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы
#### Scenario: Сбой протокола своего HTTP
- **WHEN** при пересылке запроса свой HTTP оборвал ответ (`http.client.HTTPException`)
- **THEN** в `resp/` ответ со `status = 502` и текстом ошибки; реле продолжает работу
#### Scenario: Повторная подготовка не трогает взятые запросы
- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/`
- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «удалён незавершённый запрос»
### Requirement: Публикация версии агента
`sdlc-hub agent publish [--module <модуль>] PATH` SHALL публиковать файл агента модуля (по умолчанию `jira`;
неизвестный модуль или модуль без команд — код 2 и список модулей с агентами в stderr): текст в UTF-8 не больше 1 МБ,
ровно одна строка `AGENT_VERSION = <целое>`, версия больше опубликованной для этого модуля. Файл SHALL записываться в
`<SDLC_HUB_HOME>/release/<модуль>/agent.py` (каталоги 0700), затем `manifest.json` `{version, sha256, size,
published_at}`; оба — через временный файл с `fsync` и переименование, манифест последним. Прежний `agent.py` на время
публикации SHALL переименовываться в `agent.py.prev` и удаляться после записи манифеста; если запись нового файла или
манифеста не удалась (`OSError`), хаб SHALL вернуть `agent.py.prev` на место `agent.py`, удалить временные файлы и
завершить команду сообщением «не удалось записать публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя
версия. Команда SHALL работать без запущенного `serve` и печатать «опубликована v<N>, sha256 <первые 12 символов>…,
<size> байт»; при ошибке — сообщение и код 1, опубликованное не меняется. При старте хаб SHALL один раз перенести
прежнюю публикацию `release/agent.py` и `release/manifest.json` в `release/jira/`, если `release/jira/` ещё нет; ошибка
переноса — предупреждение в лог, хаб стартует. Хаб SHALL читать манифест модуля агента при каждом `claim`,
`GET /agent/release` и `GET /agent`; манифест годен, только если это объект, `version` — целое число (не логическое) не
меньше 1, `sha256` — строка из 64 символов `[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и
предупреждение в лог — одно на каждый новый текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем
sha256, означает «публикации нет» (`GET /agent/release` — 404 `no_release`). `GET /agent?module=<модуль>` (без
параметра — `jira`) и каждый элемент `GET /agents` SHALL дополняться полями `release` (`{version, sha256,
published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` этого агента с момента
запуска хаба) и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии
агента, агент онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v<to> на v<from>: <error>»
(`rolled_back`), «v<to> отвергнута: <error>» (`rejected`), иначе `null`.
#### Scenario: Публикация новой версии
- **WHEN** опубликована v5, `sdlc-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6`
- **THEN** в `release/jira/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6,
sha256 …»; код 0
#### Scenario: Публикация eva-agent
- **WHEN** `sdlc-hub agent publish --module wiki eva-agent/src/agent.py` с `AGENT_VERSION = 1`, опубликована jira v14
- **THEN** в `release/wiki/` — v1; `claim` агента `wiki` получает `agent_release {version: 1, …}`, `claim` агента `jira` —
`{version: 14, …}`
#### Scenario: Неизвестный модуль
- **WHEN** `sdlc-hub agent publish --module nope agent.py`
- **THEN** код 2, в stderr «неизвестный модуль nope» и список модулей с агентами; `release/` не изменён
#### Scenario: Версия не выше опубликованной
- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6`
- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён
#### Scenario: Нет строки версии
- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками
- **THEN** сообщение об ошибке, код 1; `release/` не изменён
#### Scenario: Диск полон при записи манифеста
- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError`
- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8,
`GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/jira/` нет
#### Scenario: Негодный манифест
- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim`
- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте
#### Scenario: Агент без запускателя
- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null`
- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную»
#### Scenario: Хаб перезапущен, агент ещё не выходил на связь
- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было
- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого
`claim` — `agent_reported = true`, `launcher_version` из `claim`
#### Scenario: Перенос прежней публикации
- **WHEN** хаб с `release/agent.py` v14 и манифестом без `release/jira/` запущен на новом коде
- **THEN** публикация v14 лежит в `release/jira/`, `claim` агента `jira` получает `agent_release {version: 14, …}`
### Requirement: Раздача веб-приложения
Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/sdlc_hub/web/app/`, читая файлы
с диска на каждый запрос:
- `GET /app/assets/<путь>` — файл из `assets/` сборки; путь, который после разрешения выходит за `assets/`, не
существует или содержит нулевой байт, — 404; `Content-Type` по расширению (`.js` — `text/javascript; charset=utf-8`, `.css` —
`text/css; charset=utf-8`, `.svg` — `image/svg+xml`, `.png` — `image/png`, `.ico` — `image/x-icon`, `.woff2` —
`font/woff2`, `.map` — `application/json`, прочее — `application/octet-stream`);
`Cache-Control: public, max-age=31536000, immutable`;
- любой другой `GET /app` или `GET /app/…` — `index.html` сборки, `text/html; charset=utf-8`, `Cache-Control: no-cache`;
- если `index.html` сборки нет — на любой `GET /app…` 503 `text/html; charset=utf-8` с текстом, что приложение не
собрано и нужно выполнить `make web` в каталоге sdlc-hub;
- `GET /` и `GET /links` — 302 с `Location: /app/board`.
Сборка приложения не хранится в git; её создаёт `make web`.
#### Scenario: Файл сборки
- **WHEN** в сборке есть `assets/index-abc123.js`, запрошен `GET /app/assets/index-abc123.js`
- **THEN** 200, `Content-Type: text/javascript; charset=utf-8`, `Cache-Control` с `immutable`, тело — содержимое файла
#### Scenario: Путь приложения
- **WHEN** запрошен `GET /app/issues/AURORA-1620`
- **THEN** 200, тело — `index.html` сборки, `Cache-Control: no-cache`
#### Scenario: Выход за каталог сборки
- **WHEN** запрошен `GET /app/assets/../../api.py` (в том числе в виде `%2e%2e`)
- **THEN** 404, содержимое файлов вне `assets/` не отдаётся
#### Scenario: Приложение не собрано
- **WHEN** каталога сборки нет, запрошен `GET /app/board`
- **THEN** 503, тело — HTML с подсказкой `make web`
#### Scenario: Старый адрес доски
- **WHEN** запрошен `GET /links` или `GET /`
- **THEN** 302, `Location: /app/board`
#### Scenario: Нулевой байт в пути
- **WHEN** запрошен `GET /app/assets/%00` или `GET /app/assets/a%00.js`
- **THEN** 404 `not_found`, а не 500
## ADDED Requirements
### Requirement: Каталог данных прежнего имени
Если `SDLC_HUB_HOME` не задан, `~/.local/share/sdlc-hub/` не существует, а `~/.local/share/jira-hub/` существует, любая
команда `sdlc-hub`, которой нужен каталог данных (`serve`, `mcp`, `tokens`, `init`, `import`, `agent publish`), SHALL
завершиться с кодом 2 и текстом в stderr: каталог данных лежит под прежним именем, перенести его —
`scripts/cutover.sh` (или `mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub` при остановленном хабе). Команда MUST NOT
создавать новый каталог, переносить или менять прежний. Если существуют оба каталога, используется новый, в stderr —
предупреждение, что прежний можно удалить после проверки.
#### Scenario: Код обновлён, каталог не перенесён
- **WHEN** есть только `~/.local/share/jira-hub/`, запущен `sdlc-hub serve`
- **THEN** код выхода 2, в stderr — подсказка о переносе; `~/.local/share/sdlc-hub/` не создан, прежний каталог не тронут
#### Scenario: Задан SDLC_HUB_HOME
- **WHEN** `SDLC_HUB_HOME=/tmp/x`, есть `~/.local/share/jira-hub/`
- **THEN** команда работает с `/tmp/x`, проверка прежнего каталога не выполняется
### Requirement: Переключение живого хаба
`scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL
только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке:
1. проверки: основной checkout `jira-bridge/jira-hub` чистый, на `main` и совпадает с `origin/main` (владелец сделал
`git pull --ff-only` прямо перед запуском: скрипт приходит этим pull; неотслеживаемая прежняя сборка `src/jira_hub/`
без tracked-файлов не считается грязью); в Gitea доступен `aep/sdlc-hub` (владелец переименовал репозиторий; адрес
выводится из `origin` заменой `/aep/jira-hub` на `/aep/sdlc-hub`, протокол сохраняется, `CUTOVER_REMOTE`
переопределяет); есть `uv`, `make`, `node`, `npm`, `claude`, `launchctl`; `~/.local/share/sdlc-hub/` и `jira-bridge/sdlc-hub` не существуют;
2. остановить launchd `ru.aepif.jira-hub`, дождаться закрытия порта и переименовать его plist в `*.pre-sdlc`;
3. перенести каталог данных `~/.local/share/jira-hub` → `~/.local/share/sdlc-hub`;
4. перенести checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub`, удалить неотслеживаемый `src/jira_hub`,
`git worktree repair`, `origin` → `aep/sdlc-hub`, пересоздать `.venv` (`uv sync`), `make web`;
5. поставить и запустить launchd `ru.aepif.sdlc-hub` (`uv run --project <checkout> sdlc-hub serve`, лог
`~/Library/Logs/sdlc-hub.log`) и дождаться ответа хаба на порту из настроек;
6. в проектах-потребителях (по умолчанию `~/projects/awp-ca`, `~/projects/stocks_drrk` и каждый
`~/projects/*/.mcp.json`, где есть `jira-hub`, например `sfm_foxpro`; сухой прогон печатает полный список): в `.mcp.json` заменить записи
`jira-hub` → `sdlc-jira` (`sdlc-hub mcp jira`) и `jira-hub-wiki` → `sdlc-wiki` (`sdlc-hub mcp wiki`) с новым путём; в
`.claude/settings*.json` — имена серверов (`enabledMcpjsonServers` и т. п.) и разрешения `mcp__jira-hub__` →
`mcp__sdlc-jira__`, `mcp__jira-hub-wiki__` → `mcp__sdlc-wiki__`; в `CLAUDE.md` и `.claude/skills/**` — те же имена
серверов и инструментов. Прочие файлы проектов (заметки, отчёты) не трогаются. Перед правкой файл копируется рядом
с суффиксом `.pre-sdlc`; коммиты скрипт не делает;
7. в user scope Claude Code заменить `jira-hub` на `sdlc-jira` (`claude mcp remove`/`add`).
Каждый выполненный шаг SHALL печататься; в конце — сводка, что осталось владельцу (`/mcp` reconnect в открытых сессиях,
`codegraph init` в checkout и worktree, удалить копии `.pre-sdlc` после проверки). Шаги откатываются в обратном порядке по инструкции из README.
#### Scenario: Сухой прогон
- **WHEN** `bash scripts/cutover.sh` без аргументов
- **THEN** напечатаны шаги и проверки, файловая система, launchd и конфигурации Claude не изменены
#### Scenario: Репозиторий в Gitea не переименован
- **WHEN** `bash scripts/cutover.sh --apply`, `aep/sdlc-hub` в Gitea не найден
- **THEN** скрипт останавливается на шаге 1 с подсказкой, launchd и каталоги не тронуты

View File

@ -0,0 +1,243 @@
## MODIFIED Requirements
### Requirement: Сервер и регистрация
`sdlc-hub mcp <модуль> [--project <метка>]` SHALL запускать MCP-сервер модуля хаба по stdio (пакет `mcp`), ходящий в
HTTP API хаба клиентским токеном из настроек; сервер называется `sdlc-<модуль>` (`sdlc-jira`, `sdlc-wiki`). Модуль
обязателен: без него — код выхода 2 и список модулей в stderr, сервер не запускается; неизвестный модуль — так же.
Общая регистрация — в user scope Claude Code как `sdlc-jira` (`uv run --project <путь> sdlc-hub mcp jira`), без
проекта. Репозиторий проекта SHALL регистрировать серверы модулей в своём `.mcp.json` (project scope) с
`--project <метка>`; эта запись перекрывает общую. Если хаб недоступен, каждый инструмент SHALL вернуть текст «хаб не
запущен: sdlc-hub serve», а не исключение.
#### Scenario: Хаб выключен
- **WHEN** вызван `issues` при остановленном хабе
- **THEN** ответ инструмента — одна строка с подсказкой, сервер MCP продолжает работать
#### Scenario: Сервер модуля jira
- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA`
- **THEN** сервер называется `sdlc-jira`, список инструментов и их описания совпадают с прежним сервером `jira-hub`,
сессия привязана к `AWPCA`
#### Scenario: Модуль по умолчанию
- **WHEN** запущен `sdlc-hub mcp --project AWPCA`
- **THEN** модуля по умолчанию больше нет: код выхода 2, в stderr — подсказка указать модуль и список модулей (`jira`, `wiki`)
#### Scenario: Неизвестный модуль
- **WHEN** запущен `sdlc-hub mcp nope`
- **THEN** код выхода 2, в stderr — «неизвестный модуль nope» и список модулей (`jira`, `wiki`)
### Requirement: Инструменты
| Инструмент | Параметры | Результат |
|---|---|---|
| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен |
| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки |
| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#<comment_id>», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: #<id> <имя>, …»; затем вложения (см. «Вложения в MCP») |
| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка «<key> #<id> <автор>, создан <created>» (и «, изменён <updated>», если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у <key> нет комментария #<id>»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «#<id> <имя> (<размер>)» |
| `attachment` | `key`, `attachment` (номер вложения «#<id>» или «<id>», иначе — точное имя файла), `wait_seconds` (по умолчанию 120, не больше 600) | скачивает вложение и отдаёт путь к локальной копии (см. «Вложения в MCP») |
| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту |
| `batches` | `limit` | последние пакеты со статусом |
| `command_retry` | `id` | новый пакет |
| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» |
| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` |
| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть |
| `sync_log` | `limit` | последние `sync_run` |
| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` |
Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту».
Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в
`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную.
`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на
машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на
`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента
с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file».
Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки
других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб.
Ответ `project_create` SHALL содержать фрагмент `.mcp.json`
`{"mcpServers": {"sdlc-jira": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "sdlc-hub", "mcp", "jira",
"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP
(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить
с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта).
Автор команд из MCP SHALL быть `claude`; из API без автора — `api`.
#### Scenario: Пакет из чата
- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}`
- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах
#### Scenario: Проект из чата
- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}`
- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"`
#### Scenario: Ошибка с файлом из чата
- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …},
ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}`
- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: <sha>, filename: "ОСВ сч. 60 за 2 кв.
2026.xlsx"}` и хвостовым `refresh`
#### Scenario: Файла нет
- **WHEN** тот же вызов, файла по `path` нет
- **THEN** ответ «файл не найден: <path>», пакет не создан, в хаб ничего не загружено
#### Scenario: Ожидание результата
- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут
- **THEN** ответ через 6 минут со статусами всех команд
#### Scenario: Статус агента с довыдачей
- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта
- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2
#### Scenario: Отмена пакета с командой у агента
- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed`
- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …»
#### Scenario: Статус агента после отката
- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7
- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и
«откат с v7 на v6: …»
#### Scenario: Статус агента сразу после перезапуска хаба
- **WHEN** хаб перезапущен, агент ещё не прислал `claim`
- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска
хаба)» и не содержит «без запускателя» и «агент запущен без run.py»
#### Scenario: Задача с семью комментариями
- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов
- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#<comment_id>» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2»
#### Scenario: Комментарий целиком
- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}`
- **THEN** ответ — «AURORA-9001 #101 <автор>, создан <created>» и полный текст комментария без обрезки, переносы строк сохранены
#### Scenario: Нет такого комментария
- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999
- **THEN** ответ «у AURORA-9001 нет комментария #999»
### Requirement: Привязка сессии к проекту
`sdlc-hub mcp <модуль> --project <метка>` SHALL привязывать сервер MCP к проекту. Привязка SHALL проверяться при каждом вызове
инструмента по `GET /projects`: метки нет — ответ «проект <метка> не заведён в хабе: project_create или исправьте
--project в .mcp.json» (кроме `projects`, `project_create`).
В привязанной к проекту P сессии:
- `issues` — `GET /issues?project=P` с остальными фильтрами;
- `issue`, `comment` — любая задача по ключу;
- `notes_set` — только своя задача P (метка P; соседи — нет) или временный ключ `new:<n>` (его задача создаётся пакетом
проекта), иначе ответ «<key> не в проекте P»;
- `batch_create`, `command_retry` — с `project = P` (проверки пакета проекта, см. `commands`; повтор команды другого
проекта или пакета без проекта — отказ хаба);
- `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения;
- `refresh` — `full` — общий полный прогон всех проектов, `keys` — как раньше.
Без привязки инструменты `issues`, `issue`, `comment`, `notes_set`, `batch_create`, `command_retry`, `refresh` SHALL отвечать
«сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)» и в хаб не ходить; `projects`,
`project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`,
`batch_cancel` работают.
#### Scenario: Сессия без проекта
- **WHEN** MCP запущен без `--project`, вызван `issues`
- **THEN** ответ «сессия не привязана к проекту: …», запросов к `GET /issues` нет
#### Scenario: Задачи своего проекта
- **WHEN** MCP запущен с `--project STOCKS_DRRK`, вызван `issues`
- **THEN** в таблице только свои задачи `STOCKS_DRRK` и их соседи
#### Scenario: Создание задачи без метки
- **WHEN** сессия `STOCKS_DRRK`, `batch_create {commands: [ensure_issue {temp_key: new:1, summary: "…"}]}` без `labels`
- **THEN** пакет поставлен с `project = STOCKS_DRRK`, у `ensure_issue` `labels = ["STOCKS_DRRK"]`
#### Scenario: Опечатка в проекте
- **WHEN** MCP запущен с `--project STOKS`, такого проекта нет; вызван `issues`
- **THEN** ответ «проект STOKS не заведён в хабе: …»
### Requirement: Вложения в MCP
`issue` SHALL после комментариев выводить «вложения (N):» и по строке на каждое вложение (новые сверху): «#<id> <имя>
(<размер>) — <автор>, <created>», дальше через запятую: «в описании», если на вложение ссылается описание, «в #<comment_id>»
для каждого ссылающегося комментария, «скачано», если `state = ready`, «больше 10 МБ — только в Jira» для таких файлов;
без вложений — «вложений нет». Размер — в байтах, КБ или МБ с одним знаком после запятой («512 Б», «234,4 КБ», «2,3 МБ»);
неизвестный — «размер неизвестен».
`attachment` SHALL:
1. найти вложение по `GET /issues/{key}`: по номеру, иначе по точному имени (несколько с одним именем — самое новое, в
ответ строка «у <key> N вложений «<имя>», взято самое новое #<id>»); не найдено — «у <key> нет вложения «<значение>»;
список — issue <key>», в хаб больше не ходить;
2. `POST /attachments/{id}/fetch` с автором `claude`; 413 — «файл больше 10 МБ (<размер>) — скачайте в Jira:
задача <key>, вложение «<имя>»»; 409 и прочие ошибки хаба — их текст;
3. пока `state = fetching` — опрашивать `GET /attachments/{id}` раз в 2 с, не дольше `wait_seconds`; не дождался —
«скачивание ещё идёт (команда #<command_id>, агент <онлайн/офлайн>); вызовите attachment ещё раз — вторая команда не
поставится»; `failed` — «не удалось скачать: <error>»;
4. при `ready` — `GET /attachments/{id}/content` и записать файл в `<каталог данных хаба>/downloads/<id>/<имя>`, где имя —
`filename` с заменой `/`, `\`, `:` и управляющих символов на `_` (пустое — `attachment-<id>`); каталог с правами
`0700`; запись через временный файл и переименование; если там уже лежит файл того же размера — не перезаписывать;
5. ответить «<абсолютный путь>» отдельной строкой и под ним «<имя> (<размер>), <mime_type>, задача <key>».
#### Scenario: Скриншот из комментария
- **WHEN** `comment {key: "AURORA-1650", comment_id: "101"}` показал «вложения: #31337 скрин 1.png (234,4 КБ)»; вызван
`attachment {key: "AURORA-1650", attachment: "#31337"}`, агент онлайн, файла у хаба нет
- **THEN** поставлена `fetch_attachment`; через несколько секунд ответ — путь
`…/sdlc-hub/downloads/31337/скрин 1.png` и строка «скрин 1.png (234,4 КБ), image/png, задача AURORA-1650»; файл по пути
читается
#### Scenario: Повторный вызов
- **WHEN** тот же вызов второй раз, файл у хаба (`ready`) и в `downloads/31337/`
- **THEN** ответ сразу, команд в очереди не прибавилось
#### Scenario: Агент не успел
- **WHEN** `attachment {…, wait_seconds: 30}`, агент занят долгим `refresh`
- **THEN** через 30 с ответ «скачивание ещё идёт (команда #N, агент онлайн); вызовите attachment ещё раз — вторая
команда не поставится»
#### Scenario: По имени
- **WHEN** `attachment {key: "AURORA-1650", attachment: "ОСВ 60.xlsx"}`, вложение с таким именем одно
- **THEN** скачивается оно; путь оканчивается на `/ОСВ 60.xlsx`
#### Scenario: Вложения в карточке
- **WHEN** `issue {key: "AURORA-1650"}`, у задачи вложения #31337 «скрин 1.png» (на него ссылается комментарий 101,
скачано) и #31338 «запись.mp4» на 25 МБ
- **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у
комментария 101 строка «файлы: #31337 скрин 1.png»
### 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, дата синхронизации, вложения с `id`) и Markdown с `offset` до 20 000 символов, в конце — «продолжение: offset=<N>», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами |
| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент |
| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-<id>/<имя>`; иначе состояние и подсказка повторить |
| `sync` | `wait` (по умолчанию `false`) | ставит синхронизацию (или сообщает о текущей) и id пакета; с `wait` — ждёт до 300 с и отдаёт итог |
| `status` | — | корень, последняя синхронизация, ошибка, пакет в работе, агент `wiki`: онлайн/офлайн, версия, публикация, обновление, запускатель (как `agent_status` сервера jira) |
Инструменты, кроме `projects` и `project_create`, SHALL требовать привязки к проекту. В привязанной сессии без корня
(кроме `root_set`, `status`) ответ SHALL быть «у проекта <метка> нет корня вики: root_set DOC-…»; с корнем, но без
синхронизаций — «вики проекта ещё не синхронизирована: sync». Набор инструментов и их описания у сервера `sdlc-jira`
(модуль jira) SHALL остаться прежним.
#### 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

View File

@ -0,0 +1,39 @@
## MODIFIED Requirements
### Requirement: Инициализация настроек
`sdlc-hub init --config <admin/jira-sync/config.example.json>` SHALL перенести в `setting`: `project_key`, типы задач,
`epic_name_field_id`, `fields`, `epic_link`, `priority_map`, `labels`, `status_labels`, `kind_labels`, `comment_marker`,
`pacing` (в новую форму по типам команд), `transitions.map`, `link_types`; сгенерировать два токена; завести проект
`AWPCA` «АРМ КА» с `board_id` из `sprints.board_id` конфига (если проекта `AWPCA` ещё нет). Поля `auth`, `base_url`,
`verify_ssl`, `ca_bundle` MUST NOT переноситься (они у агента).
#### Scenario: Повторный init
- **WHEN** `init` вызван на уже инициализированной базе
- **THEN** настройки не перезаписываются без `--force`, токены сохраняются; проект `AWPCA` не дублируется
#### Scenario: Первый init
- **WHEN** `init --config` на пустой базе, в конфиге `sprints.board_id = 4216`
- **THEN** есть проект `AWPCA` «АРМ КА» с `board_id = 4216`; в `setting` нет `base_jql` и `board_id`
### Requirement: Импорт CSV-пакета
`sdlc-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить
`issue_note` по ключу Jira: статус по факту, вид работы, что сделано, что осталось, внутренние колонки. Строки
`new_tasks.csv` без ключа SHALL получать временный ключ `new:<номер строки>`; строки `tasks.csv` и `epics.csv` без ключа
SHALL пропускаться. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи против зеркала
после первого `refresh`), не как команды. Связь, которой в зеркале нет как `source→target` того же типа, но есть как
`target→source` того же типа, SHALL попадать в `reversed_links`, а не в `missing_links`. Импорт идемпотентен: повтор
обновляет заметки, не дублирует.
#### Scenario: Импорт и первая синхронизация
- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён
- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи
из CSV, которых нет в зеркале, связи из `links.csv`, которых нет в `issue_link`, и связи, которые есть в обратную сторону
#### Scenario: Связь в обратную сторону
- **WHEN** в `links.csv` строка «AURORA-1445 → AURORA-1446, связанные», а в зеркале есть только «ASUPR_Связанные»
1446→1445
- **THEN** связь в `reversed_links`, в `missing_links` её нет
#### Scenario: Эпик без ключа
- **WHEN** в `epics.csv` строка без значения в колонке «Ключ»
- **THEN** строка пропущена, заметка `new:*` для неё не создаётся

View File

@ -0,0 +1,169 @@
## MODIFIED Requirements
### Requirement: Вход по токену
Приложение SHALL работать с API хаба того же хоста под клиентским токеном: хранить его в `localStorage` под ключом
`sdlc-hub-token` (и в памяти, если хранилище закрыто) и передавать в заголовке `Authorization: Bearer` каждого запроса.
Если токена нет или любой запрос ответил 401, приложение SHALL показывать экран входа вместо страниц: поле токена с
подсказкой `uv run sdlc-hub tokens`, без панелей страниц и без фоновых запросов, которые могли бы изменить поле ввода.
Введённый токен SHALL проверяться запросом `GET /settings` и сохраняться только при успехе. Действие «Сменить токен»
SHALL очищать токен и показывать экран входа. Ответ 401 SHALL выводить на экран входа, только если запрос ушёл с текущим токеном: запоздавший 401 на
запрос с прежним токеном после входа с новым не учитывается. Если проверка токена получила ответ 4xx, кроме 401,
экран входа SHALL показывать текст ошибки хаба, а не «хаб не отвечает».
Ключи `localStorage` приложения с префиксом `sdlc-hub-` (`sdlc-hub-token`, `sdlc-hub-sidebar`, `sdlc-hub-legend`) SHALL
при первом чтении, если ключа нет, брать значение одноимённого ключа с префиксом `jira-hub-`, записывать его под новым
ключом и удалять прежний.
#### Scenario: Первый вход
- **WHEN** приложение открыто впервые, токена нет
- **THEN** показан экран входа; после ввода верного токена открывается запрошенная страница, токен сохранён
#### Scenario: Устаревший токен
- **WHEN** сохранённый токен не подходит и запрос ответил 401
- **THEN** показан экран входа с пояснением, что токен не подошёл; панели доски и задач не видны
#### Scenario: Токен от прежней доски
- **WHEN** в `localStorage` нет `sdlc-hub-token`, но есть `jira-hub-token` (сохранён до переименования хаба)
- **THEN** приложение входит с ним без запроса токена; значение перенесено в `sdlc-hub-token`, `jira-hub-token` удалён
#### Scenario: Запоздавший 401 от прежнего токена
- **WHEN** запрос ушёл с прежним токеном, пользователь вошёл с новым, и после этого запрос ответил 401
- **THEN** приложение остаётся на странице, новый токен сохранён
#### Scenario: Хаб отверг проверку токена
- **WHEN** проверка `GET /settings` ответила 400 с `detail`
- **THEN** экран входа показывает текст `detail`, а не «хаб не отвечает»
### Requirement: Оболочка и адреса
Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список
задач), `/app/feed` (лента изменений, требование «Лента изменений») и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь —
на страницу «не найдено» со ссылками на разделы. Боковая панель SHALL быть закреплена у левого края окна на всю его
высоту и MUST NOT прокручиваться вместе со страницей; страница занимает остальную ширину. Сверху вниз панель SHALL
содержать: название «SDLC Hub» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий
отмечен, в конце «+ Новый проект…»; требование «Проект в приложении»); разделы «Доска связей», «Задачи» и «Лента» — вертикальным
списком ссылок со значками (текущий отмечен `aria-current="page"`); внизу — блок данных Jira с действием «Обновить из
Jira» (требование «Обновление из Jira»), переключатель темы (требование «Тема») и меню с действием «Сменить токен».
Свёрнутая панель SHALL быть узкой полосой с теми же действиями значками: «Развернуть панель», метка текущего проекта,
разделы, «Обновить из Jira» с точкой состояния агента, тема, меню; названия — в подсказках и `aria-label`. Свёртка
SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar` (`collapsed`; без записи — развёрнута). На экранах уже
640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», названием и кнопкой «Обновить из
Jira»; «Меню» открывает развёрнутую панель поверх страницы, панель закрывается выбором раздела, клавишей Escape или
щелчком мимо неё. Горизонтальной прокрутки страницы на любой ширине нет.
Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не пустым экраном.
#### Scenario: Переход между разделами
- **WHEN** на доске в боковой панели выбран раздел «Задачи»
- **THEN** адрес — `/app/issues` с текущим проектом, показан список задач; «назад» возвращает на доску
#### Scenario: Раздел «Лента»
- **WHEN** на доске в боковой панели выбран раздел «Лента»
- **THEN** адрес — `/app/feed` с текущим проектом, показана лента изменений за сегодня; раздел отмечен в панели
#### Scenario: Сменить токен
- **WHEN** в меню боковой панели выбрано «Сменить токен»
- **THEN** токен очищен, показан экран входа
#### Scenario: Панель на месте при прокрутке
- **WHEN** доска прокручена до самого низа
- **THEN** боковая панель видна целиком на прежнем месте: разделы и «Обновить из Jira» доступны без прокрутки вверх
#### Scenario: Свёрнутая панель
- **WHEN** нажата «Свернуть панель», страница перезагружена
- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; «Развернуть панель»
возвращает полную панель
#### Scenario: Узкий экран
- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи»
- **THEN** панель открылась поверх страницы, после выбора закрыта, показан список задач; горизонтальной прокрутки нет
### Requirement: Доска связей
Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений,
кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV
фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу;
карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием;
фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «соседи по связям», «только
передачи», «скрыть без связей», «критический путь», «по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица
связей; экспорт и импорт JSON; «Сбросить раскладку». Раскладка, дорожки и фильтры SHALL храниться в
`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей.
Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
на одних и тех же данных и фильтрах.
Шапка доски (закреплена при прокрутке) SHALL содержать заголовок «Доска связей», счётчики (задачи из всех, соседи по
связям, связи из всех, скрытые без связей), пометку источника, если на доске снимок из файла (с действием вернуться к
данным хаба), событие доски, кнопку «Проблемы» со счётчиком и меню с действиями «Перечитать», «Загрузить файл…»,
«Экспорт JSON», «Таблица связей», «Привязка к сетке» и «Сбросить раскладку» (с подтверждением). Под шапкой — панель
управления: мультиселекты «Спринты» и «Исполнители», переключатели группы «Показать» («Соседи по связям», «Только
передачи», «Скрыть без связей») и группы «Режим» («Критический путь», «По датам»), каждый с `aria-pressed` и прежней
подсказкой. Под панелью — легенда, которая сворачивается кнопкой «Легенда»; свёрнутая показывает число скрытых видов
работ и типов связей; состояние свёртки хранится в `localStorage` под ключом `sdlc-hub-legend`. «Обновить из Jira»,
тема и «Сменить токен» — в боковой панели приложения, на доске их нет.
У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто
и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
Карточка задачи, статус Jira которой входит в `closed_statuses`, SHALL быть приглушена (полупрозрачна), под
указателем и с фокусом клавиатуры внутри карточки — в полную яркость; стрелка связи, у которой хотя бы один конец — такая задача, SHALL быть приглушена так же.
Приглушение — только вид: модель, видимая выборка, критический путь, проблемы, раскладка и экспорт JSON от него не
зависят; переключателя нет; правило одно для данных хаба, CSV и JSON.
При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю
прокрутку; после перезагрузки страницы доска открывается сверху.
Дорожки SHALL разделяться линией; фон развёрнутых дорожек один, без чередования, чтобы подсветка колонок «по датам»
(прошедшие даты, сегодня) была видна на всех дорожках; свёрнутая дорожка выделяется фоном. У карточки задачи, статус
Jira которой не входит в `closed_statuses`, с плановой датой окончания раньше сегодняшней дата «до …» SHALL быть цветом
ошибки, с подсказкой и текстом для экранного чтения «просрочено»; это только вид, модель и раскладка от него не зависят.
#### Scenario: Раскладка переживает переход
- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными
- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же
#### Scenario: Совпадение со старой доской
- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь
и проблемы старой страницей и приложением
- **THEN** результаты совпадают
#### Scenario: Открыть карточку
- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку»
- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска
#### Scenario: Возврат с карточки
- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто
- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board`
#### Scenario: Возврат из другого раздела
- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска связей»
- **THEN** доска показана с той же прокруткой
#### Scenario: Закрытые на доске приглушены
- **WHEN** на доске AURORA-1620 «В работе», AURORA-1621 «Готово», AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621
- **THEN** карточки AURORA-1621 и AURORA-1622 и стрелка AURORA-1620 → AURORA-1621 приглушены, AURORA-1620 — нет;
под указателем AURORA-1621 — в полную яркость; критический путь и ящик «Проблемы» те же, что без приглушения
#### Scenario: Свёрнутая легенда
- **WHEN** в легенде скрыт вид работы «тестирование», легенда свёрнута, страница перезагружена
- **THEN** легенда свёрнута, рядом с кнопкой «Легенда» — «скрыто: 1»; карточек вида «тестирование» на доске нет
#### Scenario: Просрочка на доске
- **WHEN** сегодня 22.10, у AURORA-1620 статус «В работе» и срок 20.10, у AURORA-1621 «Готово» и срок 20.10, включено
«По датам»
- **THEN** дата AURORA-1620 — цветом ошибки с пометкой «просрочено», у AURORA-1621 — обычная; подсветка колонки 20.10
видна на всех дорожках, дорожки одного фона
### Requirement: Сборка и разработка
Приложение SHALL быть npm-проектом в `web/` (React, TypeScript, Vite) с точными версиями зависимостей и
`package-lock.json`. `make web` SHALL устанавливать зависимости (`npm ci`) и собирать приложение в
`src/sdlc_hub/web/app/`; каталог сборки не хранится в git. Шрифты (Mulish — текст интерфейса, IBM Plex Mono — ключи
задач и исходники), иконки и прочие ресурсы страницы SHALL входить в сборку: приложение не обращается к внешним
адресам. `make check` SHALL запускать `uv run pytest`, проверку типов и линтер фронта и тесты фронта; зелёным считается
только весь набор. `npm run dev` SHALL поднимать сервер разработки с прокси маршрутов API на хаб из `SDLC_HUB_URL` (по
умолчанию тестовый `http://127.0.0.1:8791`).
#### Scenario: Сборка с нуля
- **WHEN** в чистом checkout выполнен `make web`
- **THEN** в `src/sdlc_hub/web/app/` есть `index.html` и `assets/`; `git status` не показывает файлов сборки
#### Scenario: Без интернета
- **WHEN** приложение открыто на машине без доступа в интернет
- **THEN** страница не запрашивает ресурсов вне хаба, текст набран Mulish, ключи задач — IBM Plex Mono

View File

@ -0,0 +1,33 @@
## MODIFIED Requirements
### Requirement: Markdown-копия вики
Копия проекта SHALL лежать в `<SDLC_HUB_HOME>/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог
`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня
номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей —
суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# <title>` и текст. В
корне копии — `_tree.json`, список `{code, title, depth, path}` в порядке обхода. Копия SHALL собираться рядом
(`wiki/.<метка>.new/`) и подменять прежнюю переименованием; незавершённую подмену хаб SHALL доводить при старте.
HTML страницы SHALL переводиться в Markdown (заголовки `#`); ссылка или картинка на файл Eva (по списку `attachments` агента: адрес на хосте Eva
с путём `/files/…`, относительный адрес — тоже Eva; миниатюра `<адрес>.meta/thumbnail.jpg` означает файл `<адрес>`, а
ссылки на `static/…` — значки интерфейса — из текста удаляются) SHALL заменяться на
`[📎 <имя>](attachment:<id>)`, где `id` — строка вложения; ссылка на документ Eva с кодом `DOC-<цифры>` (в Eva — относительный
`project/Document/DOC-…`) — на `[<текст>](page:<код>)`; прочие ссылки остаются. Сырой HTML в копию не попадает.
#### Scenario: Раскладка
- **WHEN** корень DOC-1 «АРМ КА», дети DOC-3 «Статусы» (`orderno` 1) и DOC-2 «Роли/права» (`orderno` 2)
- **THEN** `wiki/AWPCA/АРМ КА/index.md`, `…/01_Статусы/index.md`, `…/02_Роли_права/index.md`; `_tree.json` в порядке
DOC-1, DOC-3, DOC-2
#### Scenario: Картинка на странице
- **WHEN** в HTML DOC-3 `<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/scheme.png?version=1">`
- **THEN** в `index.md` — `[📎 scheme.png](attachment:<id>)`, у вложения `<id>` `state = none`, файл не скачан
#### Scenario: Прикреплённый файл с миниатюрой
- **WHEN** на странице миниатюра `…/<uuid>/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и
ссылка на `…/<uuid>/model.xlsx`
- **THEN** одно вложение `model.xlsx` с адресом файла; в тексте одна ссылка `[📎 model.xlsx](attachment:<id>)`, значка нет
#### Scenario: Ссылка на другую страницу
- **WHEN** в HTML `<a href="project/Document/DOC-019834">Модель данных</a>`
- **THEN** в `index.md` — `[Модель данных](page:DOC-019834)`

View File

@ -1,5 +1,5 @@
[project] [project]
name = "jira-hub" name = "sdlc-hub"
version = "0.1.0" version = "0.1.0"
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
requires-python = ">=3.13" requires-python = ">=3.13"
@ -10,7 +10,7 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
jira-hub = "jira_hub.cli:main" sdlc-hub = "sdlc_hub.cli:main"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8"] dev = ["pytest>=8"]
@ -20,7 +20,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/jira_hub"] packages = ["src/sdlc_hub"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

499
scripts/cutover.py Normal file
View File

@ -0,0 +1,499 @@
#!/usr/bin/env python3
"""Переключение живой установки хаба jira-hub -> sdlc-hub (одноразовый скрипт, stdlib, Python >= 3.9).
Без --apply только печатает проверки и шаги, ничего не меняя. С --apply выполняет шаги 2-7 спеки sdlc-rename
по порядку и останавливается на первой ошибке.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import socket
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, List, Tuple
OLD_LABEL = "ru.aepif.jira-hub"
NEW_LABEL = "ru.aepif.sdlc-hub"
SUFFIX = ".pre-sdlc"
def _home() -> Path:
return Path(os.environ.get("HOME") or os.path.expanduser("~"))
@dataclass
class Config:
bridge: Path
data_root: Path
launch_agents: Path
logs: Path
remote: str = "" # пусто -> выводится из origin старого checkout заменой /aep/jira-hub на /aep/sdlc-hub
projects: List[Path] = field(default_factory=list)
port: int = 8790
projects_root: Path = None # где искать потребителей автоматически: <projects_root>/*/.mcp.json
@classmethod
def from_env(cls) -> "Config":
e = os.environ
h = _home()
projects = e.get("CUTOVER_PROJECTS")
return cls(
bridge=Path(e.get("CUTOVER_BRIDGE") or h / "projects" / "jira-bridge"),
data_root=Path(e.get("CUTOVER_DATA_ROOT") or h / ".local" / "share"),
launch_agents=Path(e.get("CUTOVER_LAUNCH_AGENTS") or h / "Library" / "LaunchAgents"),
logs=Path(e.get("CUTOVER_LOGS") or h / "Library" / "Logs"),
remote=e.get("CUTOVER_REMOTE") or "",
projects=[Path(p) for p in projects.split(":") if p] if projects is not None
else [h / "projects" / "awp-ca", h / "projects" / "stocks_drrk"],
port=int(e.get("CUTOVER_PORT") or 8790),
projects_root=Path(e.get("CUTOVER_PROJECTS_ROOT") or h / "projects"),
)
@property
def old_checkout(self) -> Path:
return self.bridge / "jira-hub"
@property
def new_checkout(self) -> Path:
return self.bridge / "sdlc-hub"
def _git_env() -> dict:
return dict(os.environ, GIT_OPTIONAL_LOCKS="0", GIT_TERMINAL_PROMPT="0")
def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=_git_env(),
stdin=subprocess.DEVNULL)
OLD_REPO = "/aep/jira-hub"
NEW_REPO = "/aep/sdlc-hub"
def derive_remote(origin: str) -> str:
"""Адрес нового репозитория из адреса origin: тот же протокол и суффикс, имя другое; уже новый — как есть."""
if NEW_REPO in origin:
return origin
if OLD_REPO not in origin:
raise ValueError("origin %s не содержит %s" % (origin, OLD_REPO))
return origin.replace(OLD_REPO, NEW_REPO)
def target_remote(cfg: Config) -> Tuple[str, str]:
"""(адрес, проблема): CUTOVER_REMOTE, иначе производный от origin старого checkout."""
if cfg.remote:
return cfg.remote, ""
r = _git(cfg.old_checkout, "remote", "get-url", "origin")
if r.returncode != 0:
return "", "не удалось прочитать origin в %s: %s" % (cfg.old_checkout, r.stderr.strip())
try:
return derive_remote(r.stdout.strip()), ""
except ValueError as e:
return "", "%s: задайте CUTOVER_REMOTE" % e
def _checkout_dirty(old: Path, porcelain: str) -> bool:
"""Чисто, если нет изменений; неотслеживаемая сборка src/jira_hub/ (без tracked-файлов) не считается."""
lines = [ln for ln in porcelain.splitlines() if ln.strip()]
if "?? src/jira_hub/" in lines and not _git(old, "ls-files", "src/jira_hub").stdout.strip():
lines.remove("?? src/jira_hub/")
return bool(lines)
def preflight(cfg: Config) -> List[str]:
"""Проверки шага 1. Только чтение."""
problems: List[str] = []
for tool in ("uv", "make", "claude", "launchctl", "node", "npm"):
if not shutil.which(tool):
problems.append("не найден инструмент %s в PATH (нужен для шагов переключения)" % tool)
old = cfg.old_checkout
remote = cfg.remote
if not old.is_dir():
problems.append("старый checkout %s не найден" % old)
else:
st = _git(old, "status", "--porcelain")
if st.returncode != 0:
problems.append("git status в %s не выполнился: %s" % (old, st.stderr.strip()))
elif _checkout_dirty(old, st.stdout):
problems.append("checkout %s не чист (есть незакоммиченные изменения)" % old)
br = _git(old, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
if br != "main":
problems.append("checkout %s не на ветке main (сейчас: %s)" % (old, br or "?"))
head = _git(old, "rev-parse", "HEAD").stdout.strip()
if not remote:
remote, prob = target_remote(cfg)
if prob:
problems.append(prob)
# сверка с новым адресом: после переименования Gitea не перенаправляет git по SSH, старый origin недоступен;
# ls-remote вместо fetch: проверка остаётся строго чтением (fetch пишет FETCH_HEAD)
if remote:
lr = _git(old, "ls-remote", remote, "refs/heads/main")
remote_head = lr.stdout.split()[0] if lr.returncode == 0 and lr.stdout.split() else ""
if remote_head and remote_head != head:
problems.append("HEAD не совпадает с main в %s: сделайте git pull --ff-only в %s (origin — %s)"
% (remote, old, remote))
if remote:
lr = subprocess.run(["git", "ls-remote", remote], capture_output=True, text=True, env=_git_env(),
stdin=subprocess.DEVNULL)
if lr.returncode != 0:
problems.append("репозиторий %s недоступен: переименуйте aep/jira-hub -> aep/sdlc-hub в Gitea (%s)"
% (remote, lr.stderr.strip().splitlines()[-1] if lr.stderr.strip() else "нет ответа"))
for p in (cfg.data_root / "sdlc-hub", cfg.new_checkout):
if p.exists():
problems.append("каталог %s уже существует" % p)
if not (cfg.data_root / "jira-hub").exists():
problems.append("каталог данных %s не найден" % (cfg.data_root / "jira-hub"))
return problems
def _pgrep_mcp() -> List[str]:
"""Процессы MCP старого имени (сессии Claude). Нет pgrep - пропуск. Только чтение."""
if not shutil.which("pgrep"):
return []
r = subprocess.run(["pgrep", "-fl", "jira-hub mcp"], capture_output=True, text=True, stdin=subprocess.DEVNULL)
return [ln for ln in r.stdout.splitlines() if ln.strip()]
def warnings(cfg: Config) -> List[str]:
"""Предупреждения (не блокируют): открытые сессии Claude со старым MCP заново создадут каталог данных."""
procs = _pgrep_mcp()
if not procs:
return []
return ["работают процессы `jira-hub mcp` (закройте сессии Claude или после шага 3 они заново создадут "
"%s):" % (cfg.data_root / "jira-hub")] + [" " + p for p in procs]
def consumer_projects(cfg: Config) -> List[Path]:
"""Список проектов-потребителей: заданные плюс найденные <projects_root>/*/.mcp.json с `jira-hub`."""
out: List[Path] = list(cfg.projects)
root = cfg.projects_root
if root is not None and root.is_dir():
for mcp in sorted(root.glob("*/.mcp.json")):
proj = mcp.parent
if proj in out or proj == cfg.bridge or proj.resolve() == cfg.old_checkout.resolve():
continue
try:
if "jira-hub" in mcp.read_text(encoding="utf-8"):
out.append(proj)
except (OSError, UnicodeDecodeError):
continue
return out
def rewrite_mcp_json(path: Path, checkout: Path, unrewritten: List[str] = None) -> bool:
"""Переписывает записи jira-hub/jira-hub-wiki. Запись нестандартной формы (command не uv или нет маркера
mcp/jira-hub в args) не меняется, её описание добавляется в `unrewritten`."""
data = json.loads(path.read_text(encoding="utf-8"))
servers = data.get("mcpServers")
if not isinstance(servers, dict):
return False
names = {"jira-hub": ("sdlc-jira", "jira"), "jira-hub-wiki": ("sdlc-wiki", "wiki")}
changed = False
new: dict = {}
for key, val in servers.items():
if key not in names:
new[key] = val
continue
new_key, module = names[key]
args = list(val.get("args", []))
marker = next((m for m in ("mcp", "jira-hub") if m in args), None)
if os.path.basename(str(val.get("command", ""))) != "uv" or marker is None:
new[key] = val
if unrewritten is not None:
unrewritten.append("%s: запись %s нестандартной формы, не переписана" % (path, key))
continue
tail = args[args.index(marker) + 1:]
if tail and tail[0] in ("jira", "wiki"):
tail = tail[1:]
val = dict(val)
val["args"] = ["run", "--project", str(checkout), "sdlc-hub", "mcp", module] + tail
new[new_key] = val
changed = True
if changed:
data["mcpServers"] = new
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return changed
_REPLACEMENTS = [
("mcp__jira-hub-wiki__", "mcp__sdlc-wiki__"),
("mcp__jira-hub__", "mcp__sdlc-jira__"),
("aep/jira-hub", "aep/sdlc-hub"),
('"jira-hub-wiki"', '"sdlc-wiki"'),
('"jira-hub"', '"sdlc-jira"'),
("MCP `jira-hub`", "MCP `sdlc-jira`"),
("MCP jira-hub", "MCP sdlc-jira"),
]
def rewrite_text(path: Path) -> List[str]:
"""Замены имён серверов и инструментов; возвращает строки с остатками `jira-hub`."""
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
out = text
for a, b in _REPLACEMENTS:
out = out.replace(a, b)
if out != text:
path.write_text(out, encoding="utf-8")
return [ln.strip() for ln in out.splitlines() if "jira-hub" in ln]
def backup(path: Path) -> Path:
dst = path.with_name(path.name + SUFFIX)
shutil.copy2(str(path), str(dst))
return dst
def plist_text(checkout: Path, log: Path, uv: str) -> str:
args = "".join("<string>%s</string>" % a for a in [uv, "run", "--project", str(checkout), "sdlc-hub", "serve"])
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0"><dict>\n'
" <key>Label</key><string>%s</string>\n"
" <key>ProgramArguments</key><array>%s</array>\n"
" <key>EnvironmentVariables</key><dict>\n"
" <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>\n"
" <key>PYTHONUNBUFFERED</key><string>1</string>\n"
" </dict>\n"
" <key>RunAtLoad</key><true/>\n"
" <key>KeepAlive</key><true/>\n"
" <key>StandardOutPath</key><string>%s</string>\n"
" <key>StandardErrorPath</key><string>%s</string>\n"
"</dict></plist>\n" % (NEW_LABEL, args, log, log)
)
def _run(*cmd: str, cwd: Path = None, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(list(cmd), cwd=str(cwd) if cwd else None, check=check)
def _port_open(port: int) -> bool:
try:
socket.create_connection(("127.0.0.1", port), timeout=1).close()
return True
except OSError:
return False
_sleep = time.sleep
_clock = time.monotonic
def _wait_port_closed(port: int, timeout: float = 10.0, interval: float = 0.5) -> bool:
deadline = _clock() + timeout
while _port_open(port):
if _clock() >= deadline:
return False
_sleep(interval)
return True
def _domain() -> str:
return "gui/%d" % os.getuid()
def _project_files(project: Path) -> List[Path]:
files: List[Path] = []
claude = project / ".claude"
files += sorted(claude.glob("settings*.json")) if claude.is_dir() else []
if (project / "CLAUDE.md").is_file():
files.append(project / "CLAUDE.md")
skills = claude / "skills"
if skills.is_dir():
files += [p for p in sorted(skills.rglob("*")) if p.is_file() and not p.name.endswith(SUFFIX)]
return files
def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) -> List[Tuple[str, Callable[[], None]]]:
leftovers = leftovers if leftovers is not None else []
backups = backups if backups is not None else []
new_co = cfg.new_checkout
old_plist = cfg.launch_agents / (OLD_LABEL + ".plist")
kept_plist = old_plist.with_name(old_plist.name + SUFFIX)
new_plist = cfg.launch_agents / (NEW_LABEL + ".plist")
log = cfg.logs / "sdlc-hub.log"
uv = shutil.which("uv") or "/opt/homebrew/bin/uv"
remote_shown = cfg.remote or "производный от origin"
def stop() -> None:
_run("launchctl", "bootout", "%s/%s" % (_domain(), OLD_LABEL), check=False) # не загружен - не ошибка
if not _wait_port_closed(cfg.port):
raise RuntimeError("после остановки %s порт %d всё ещё занят: хаб не остановлен" % (OLD_LABEL, cfg.port))
if old_plist.exists():
shutil.move(str(old_plist), str(kept_plist))
def move_data() -> None:
shutil.move(str(cfg.data_root / "jira-hub"), str(cfg.data_root / "sdlc-hub"))
def move_checkout() -> None:
remote = cfg.remote
if not remote:
remote, prob = target_remote(cfg)
if prob:
raise RuntimeError(prob)
shutil.move(str(cfg.old_checkout), str(new_co))
old_src = new_co / "src" / "jira_hub"
if old_src.is_dir() and not _git(new_co, "ls-files", "src/jira_hub").stdout.strip():
shutil.rmtree(str(old_src)) # неотслеживаемая прежняя сборка web/app
wts = sorted(p for p in (new_co / ".wt").glob("*") if p.is_dir()) if (new_co / ".wt").is_dir() else []
_run("git", "-C", str(new_co), "worktree", "repair", *[str(p) for p in wts])
_run("git", "-C", str(new_co), "remote", "set-url", "origin", remote)
shutil.rmtree(str(new_co / ".venv"), ignore_errors=True)
_run("uv", "sync", cwd=new_co)
_run("make", "web", cwd=new_co)
def start() -> None:
cfg.launch_agents.mkdir(parents=True, exist_ok=True)
cfg.logs.mkdir(parents=True, exist_ok=True)
new_plist.write_text(plist_text(new_co, log, uv), encoding="utf-8")
_run("launchctl", "bootstrap", _domain(), str(new_plist))
deadline = _clock() + 30
while _clock() < deadline:
if _port_open(cfg.port):
return
_sleep(1)
raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s); остановить: launchctl bootout %s/%s"
% (cfg.port, log, _domain(), NEW_LABEL))
def consumers() -> None:
for project in consumer_projects(cfg):
mcp = project / ".mcp.json"
if mcp.is_file() and "jira-hub" in mcp.read_text(encoding="utf-8"):
backups.append(backup(mcp))
rewrite_mcp_json(mcp, new_co, leftovers)
leftovers.extend("%s: %s" % (mcp, ln.strip())
for ln in mcp.read_text(encoding="utf-8").splitlines() if "jira-hub" in ln)
for f in _project_files(project):
try:
if "jira-hub" not in f.read_text(encoding="utf-8"):
continue
except (UnicodeDecodeError, OSError):
continue
backups.append(backup(f))
leftovers.extend("%s: %s" % (f, ln) for ln in rewrite_text(f))
def user_scope() -> None:
_run("claude", "mcp", "add", "-s", "user", "sdlc-jira", "--",
"uv", "run", "--project", str(new_co), "sdlc-hub", "mcp", "jira")
r = _run("claude", "mcp", "remove", "-s", "user", "jira-hub", check=False)
if r.returncode != 0:
print(" ПРЕДУПРЕЖДЕНИЕ: claude mcp remove -s user jira-hub не выполнился (код %d): удалите запись руками"
% r.returncode)
return [
("2. остановить launchd %s, дождаться закрытия порта, переименовать %s в %s"
% (OLD_LABEL, old_plist.name, kept_plist.name), stop),
("3. перенести данные %s -> %s" % (cfg.data_root / "jira-hub", cfg.data_root / "sdlc-hub"), move_data),
("4. перенести checkout %s -> %s, убрать неотслеживаемый src/jira_hub, worktree repair, origin -> %s, "
"uv sync, make web" % (cfg.old_checkout, new_co, remote_shown), move_checkout),
("5. поставить и запустить %s (лог %s), дождаться порта %d" % (NEW_LABEL, log, cfg.port), start),
("6. править проекты %s (.mcp.json, .claude/settings*.json, CLAUDE.md, .claude/skills; копии *%s)"
% (", ".join(str(p) for p in consumer_projects(cfg)), SUFFIX), consumers),
("7. user scope Claude: add sdlc-jira, затем remove jira-hub", user_scope),
]
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser(description="Переключение живого хаба jira-hub -> sdlc-hub")
ap.add_argument("--apply", action="store_true", help="выполнить (по умолчанию сухой прогон)")
args = ap.parse_args(argv)
cfg = Config.from_env()
problems = preflight(cfg)
print("1. проверки:")
if problems:
for p in problems:
print(" ПРОБЛЕМА: " + p)
else:
print(" ок")
for w in warnings(cfg):
print(" ПРЕДУПРЕЖДЕНИЕ: " + w if not w.startswith(" ") else w)
leftovers: List[str] = []
backups: List[Path] = []
plan = steps(cfg, leftovers, backups)
if not args.apply:
print("Сухой прогон, ничего не изменено. Шаги:")
for desc, _ in plan:
print(" " + desc)
return 1 if problems else 0
if problems:
print("Остановлено: исправьте проблемы шага 1, ничего не изменено.")
return 1
done: List[str] = []
for desc, fn in plan:
print("-> " + desc)
try:
fn()
except Exception as e: # noqa: BLE001
print("остановлено на шаге %s: %s" % (desc.split(".")[0], e))
print("Выполнено: " + ("; ".join(done) if done else "ничего"))
print("Промежуточное состояние (по выполненным шагам; упавший шаг мог выполниться частично, проверьте):")
for line in _state_lines(done, desc):
print(" " + line)
old_plist = cfg.launch_agents / (OLD_LABEL + ".plist")
print("Откат: остановить ru.aepif.sdlc-hub (launchctl bootout %s/%s), перенести каталоги обратно "
"(данные %s -> %s, checkout %s -> %s, origin вернуть на aep/jira-hub), вернуть plist "
"(mv %s %s) и launchctl bootstrap %s %s; раздел «Переключение с jira-hub» в README." % (
_domain(), NEW_LABEL, cfg.data_root / "sdlc-hub", cfg.data_root / "jira-hub",
cfg.new_checkout, cfg.old_checkout, str(old_plist) + SUFFIX, old_plist, _domain(), old_plist))
_print_tail(cfg, done, backups, leftovers)
return 1
done.append(desc)
print(" готово")
print("\nОсталось владельцу:")
print(" - /mcp reconnect в открытых сессиях Claude")
print(" - codegraph init в %s и в каждом worktree (.wt/*)" % cfg.new_checkout)
print(" - uv sync в каждом worktree (.wt/*): их .venv содержат старые абсолютные пути")
print(" - после запуска проверить лог реле (%s) и agent_status; при «Operation not permitted» выдать python "
"из нового .venv (%s) полный доступ к диску заново" % (cfg.logs / "sdlc-hub.log", cfg.new_checkout / ".venv"))
print(" - скрипт лучше запускать из обычного терминала вне checkout (checkout переезжает во время работы)")
_print_tail(cfg, done, backups, leftovers)
return 0
def _state_lines(done: List[str], failed: str = "") -> List[str]:
n = {d.split(".")[0] for d in done}
out = []
if "2" in n:
out.append("хаб ru.aepif.jira-hub остановлен, его plist переименован в *%s" % SUFFIX)
if "3" in n:
out.append("каталог данных перенесён в sdlc-hub")
if "4" in n:
out.append("checkout перенесён в sdlc-hub, origin переключён")
if "5" in n:
out.append("ru.aepif.sdlc-hub установлен и запущен")
if "6" in n:
out.append("файлы проектов изменены (копии *%s)" % SUFFIX)
if failed:
if failed.startswith("2."):
out.append("упавший шаг 2: хаб ru.aepif.jira-hub мог быть выгружен; его plist мог быть переименован в *%s"
% SUFFIX)
else:
out.append("упавший шаг %s мог выполниться частично, проверьте" % failed.split(".")[0])
return out or ["ничего не изменено"]
def _print_tail(cfg: Config, done: List[str], backups: List[Path], leftovers: List[str]) -> None:
stale = cfg.data_root / "jira-hub"
if any(d.startswith("3.") for d in done) and stale.exists():
print(" - ПРЕДУПРЕЖДЕНИЕ: %s появился заново (сессия Claude со старым MCP): удалите его перед откатом mv" % stale)
print(" - проверить правки и удалить копии *%s:" % SUFFIX)
for b in backups:
print(" " + str(b))
if leftovers:
print(" - проверить руками (остались упоминания jira-hub или запись не переписана):")
for ln in leftovers:
print(" " + ln)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

2
scripts/cutover.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
exec python3 "$(dirname "$0")/cutover.py" "$@"

View File

@ -1,2 +0,0 @@
"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP."""
__version__ = "0.1.0"

View File

@ -1,34 +0,0 @@
"""Где лежат данные хаба: каталог, база, резервные копии."""
import logging
import os
from pathlib import Path
log = logging.getLogger(__name__)
def home() -> Path:
return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub")))
def db_path() -> Path:
return home() / "hub.sqlite"
def backups_dir() -> Path:
return home() / "backups"
def release_root() -> Path:
"""Корень публикаций агентов: release/<модуль>/agent.py + manifest.json."""
return home() / "release"
def secure_dir(path: Path) -> None:
"""Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) —
предупреждение, хаб продолжает работу."""
path = Path(path)
path.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path, 0o700)
except OSError as e:
log.warning("каталог %s не закрыт (0700): %s", path, e)

2
src/sdlc_hub/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""sdlc-hub — хаб SDLC: ядро (проекты, очередь команд, агенты, HTTP API, веб) и модули jira и wiki."""
__version__ = "0.1.0"

View File

@ -1 +1 @@
from .core.cli import main # noqa: F401 точка входа jira-hub from .core.cli import main # noqa: F401 точка входа sdlc-hub

View File

@ -27,8 +27,8 @@ WEB_APP = Path(__file__).resolve().parent.parent / "web" / "app" # сборк
ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml",
".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"} ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"}
ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3) ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3)
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните " NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>SDLC Hub</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8") "<code>make web</code> в каталоге sdlc-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
@ -610,7 +610,7 @@ def start(hub: Hub, host="0.0.0.0", port=8790) -> tuple[ThreadingHTTPServer, thr
def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: def serve(hub: Hub, host="0.0.0.0", port=8790) -> None:
srv, stop = start(hub, host, port) srv, stop = start(hub, host, port)
print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}", flush=True) print(f"sdlc-hub: http://{host}:{srv.server_port}, база {hub.db_path}", flush=True)
try: try:
srv.serve_forever() srv.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@ -1,4 +1,4 @@
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), mcp [модуль] (stdio), tokens, """Команды sdlc-hub: init (настройки из config.example.json), serve (HTTP), mcp [модуль] (stdio), tokens,
agent publish (версия агента для самообновления) и подкоманды модулей (jira: import).""" agent publish (версия агента для самообновления) и подкоманды модулей (jira: import)."""
import argparse import argparse
import json import json
@ -9,7 +9,7 @@ from .paths import secure_dir
def main(argv=None) -> int: def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб") ap = argparse.ArgumentParser(prog="sdlc-hub", description="SDLC-хаб: ядро и модули")
sub = ap.add_subparsers(dest="cmd", required=True) sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены") p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены")
p.add_argument("--config", required=True) p.add_argument("--config", required=True)
@ -17,8 +17,8 @@ def main(argv=None) -> int:
p = sub.add_parser("serve", help="запустить HTTP API") p = sub.add_parser("serve", help="запустить HTTP API")
p.add_argument("--host", default="0.0.0.0") p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int) p.add_argument("--port", type=int)
p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio (по умолчанию jira)") p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio ")
p.add_argument("module", nargs="?", default="jira", help="имя модуля; неизвестное — код 2 (проверка в mcp.run)") p.add_argument("module", nargs="?", default=None, help="имя модуля (jira, wiki); без модуля или неизвестный — код 2")
p.add_argument("--project", help="метка проекта, к которому привязана сессия") p.add_argument("--project", help="метка проекта, к которому привязана сессия")
for m in registry.modules(): for m in registry.modules():
if m.cli_register: if m.cli_register:
@ -31,6 +31,12 @@ def main(argv=None) -> int:
p.add_argument("path") p.add_argument("path")
args = ap.parse_args(argv) args = ap.parse_args(argv)
if (problem := paths.home_problem()):
print(problem, file=sys.stderr)
return 2
if (warning := paths.home_warning()):
print(warning, file=sys.stderr)
if args.cmd == "agent": if args.cmd == "agent":
from . import release from . import release
names = [m.name for m in registry.agent_modules()] names = [m.name for m in registry.agent_modules()]

View File

@ -14,7 +14,7 @@ class HubError(Exception):
class HubUnavailable(Exception): class HubUnavailable(Exception):
"""Хаб не отвечает — обычно не запущен (jira-hub serve).""" """Хаб не отвечает — обычно не запущен (sdlc-hub serve)."""
class HubClient: class HubClient:

View File

@ -14,7 +14,7 @@ from pathlib import Path
from . import paths, registry from . import paths, registry
from .client import HubClient, HubError, HubUnavailable from .client import HubClient, HubError, HubUnavailable
OFFLINE = "хаб не запущен: jira-hub serve" OFFLINE = "хаб не запущен: sdlc-hub serve"
AUTHOR = "claude" AUTHOR = "claude"
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)" NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19) HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19)
@ -25,13 +25,10 @@ def unknown_project(label) -> str:
return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json" return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"
def mcp_json(label: str, module: str = registry.DEFAULT_AGENT) -> dict: def mcp_json(label: str, module: str) -> dict:
"""Фрагмент .mcp.json для репозитория проекта: у модуля по умолчанию прежний (сервер jira-hub), у прочих модулей — """Фрагмент .mcp.json для репозитория проекта: сервер sdlc-<модуль>, запуск `sdlc-hub mcp <модуль>`."""
сервер jira-hub-<модуль>.""" run = ["run", "--project", str(HUB_ROOT), "sdlc-hub", "mcp", module, "--project", label]
run = ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp"] return {"mcpServers": {f"sdlc-{module}": {"command": "uv", "args": run}}}
if module == registry.DEFAULT_AGENT:
return {"mcpServers": {"jira-hub": {"command": "uv", "args": [*run, "--project", label]}}}
return {"mcpServers": {f"jira-hub-{module}": {"command": "uv", "args": [*run, module, "--project", label]}}}
def client() -> HubClient: def client() -> HubClient:
@ -199,9 +196,13 @@ def register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Call
mcp.tool()(project_create) mcp.tool()(project_create)
def run(module_name: str, project: str | None = None) -> int: def run(module_name: str | None, project: str | None = None) -> int:
"""Запустить MCP-сервер модуля по stdio. Неизвестный модуль: список модулей в stderr, код 2, сервер не стартует.""" """Запустить MCP-сервер модуля по stdio. Неизвестный модуль: список модулей в stderr, код 2, сервер не стартует."""
global PROJECT global PROJECT
if module_name is None:
names = ", ".join(x.name for x in registry.modules() if x.mcp_server)
print(f"укажите модуль: sdlc-hub mcp <модуль> [--project <метка>]; модули: {names}", file=sys.stderr)
return 2
m = registry.module(module_name) m = registry.module(module_name)
if m is None or m.mcp_server is None: if m is None or m.mcp_server is None:
names = ", ".join(x.name for x in registry.modules() if x.mcp_server) names = ", ".join(x.name for x in registry.modules() if x.mcp_server)

View File

@ -0,0 +1,55 @@
"""Где лежат данные хаба: каталог, база, резервные копии."""
import logging
import os
from pathlib import Path
log = logging.getLogger(__name__)
def home() -> Path:
return Path(os.environ.get("SDLC_HUB_HOME", str(Path.home() / ".local" / "share" / "sdlc-hub")))
def legacy_home() -> Path:
"""Каталог данных под прежним именем (до переименования в sdlc-hub)."""
return Path.home() / ".local" / "share" / "jira-hub"
def home_problem() -> "str | None":
"""Текст отказа, если данные лежат только под прежним именем; при заданном SDLC_HUB_HOME проверки нет."""
if os.environ.get("SDLC_HUB_HOME") or home().exists() or not legacy_home().exists():
return None
return ("каталог данных хаба лежит под прежним именем ~/.local/share/jira-hub; перенесите его: "
"bash scripts/cutover.sh (или mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub при остановленном хабе)")
def home_warning() -> "str | None":
"""Предупреждение, если существуют оба каталога (SDLC_HUB_HOME не задан)."""
if os.environ.get("SDLC_HUB_HOME") or not (home().exists() and legacy_home().exists()):
return None
return ("есть и ~/.local/share/sdlc-hub, и ~/.local/share/jira-hub: работаю с новым, "
"прежний каталог ~/.local/share/jira-hub можно удалить после проверки")
def db_path() -> Path:
return home() / "hub.sqlite"
def backups_dir() -> Path:
return home() / "backups"
def release_root() -> Path:
"""Корень публикаций агентов: release/<модуль>/agent.py + manifest.json."""
return home() / "release"
def secure_dir(path: Path) -> None:
"""Создать каталог и закрыть его владельцу (0700): в базе и копиях лежат токены (M-7). Сбой chmod (каталог чужой) —
предупреждение, хаб продолжает работу."""
path = Path(path)
path.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path, 0o700)
except OSError as e:
log.warning("каталог %s не закрыт (0700): %s", path, e)

View File

@ -1,4 +1,4 @@
"""Подкоманды модуля jira для jira-hub: import (CSV-пакет admin/jira-sync в заметки).""" """Подкоманды модуля jira для sdlc-hub: import (CSV-пакет admin/jira-sync в заметки)."""
import json import json
from ...core import db, paths, settings from ...core import db, paths, settings

View File

@ -19,7 +19,7 @@ from .commands import describe, is_temp
POLL_SECONDS = 5 POLL_SECONDS = 5
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
mcp = FastMCP("jira-hub") mcp = FastMCP("sdlc-jira")
fmt_agent = core.fmt_agent fmt_agent = core.fmt_agent

View File

@ -1,4 +1,4 @@
"""MCP-инструменты модуля wiki (stdio, FastMCP, сервер jira-hub-wiki) — инструменты сессии Claude поверх HTTP API хаба """MCP-инструменты модуля wiki (stdio, FastMCP, сервер sdlc-wiki) — инструменты сессии Claude поверх HTTP API хаба
(specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и (specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и
имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename.""" имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename."""
import asyncio import asyncio
@ -16,7 +16,7 @@ ATTACHMENT_WAIT = 60
SYNC_WAIT = 300 SYNC_WAIT = 300
CHUNK = 20000 CHUNK = 20000
CANDIDATES = 10 CANDIDATES = 10
mcp = FastMCP("jira-hub-wiki") mcp = FastMCP("sdlc-wiki")
def _project() -> dict: def _project() -> dict:
@ -242,7 +242,7 @@ def root_set(root: str) -> str:
@core.guard @core.guard
def project_create(label: str, name: str, wiki_root: str = "") -> str: def project_create(label: str, name: str, wiki_root: str = "") -> str:
"""Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит """Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит
синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.""" синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером sdlc-wiki."""
try: try:
code = wiki_roots.parse_root(wiki_root) code = wiki_roots.parse_root(wiki_root)
except ValueError as e: except ValueError as e:

View File

@ -5,12 +5,18 @@ import threading
import pytest import pytest
from jira_hub.core import api, db, projects, registry, settings from sdlc_hub.core import api, db, projects, registry, settings
from jira_hub.core.client import HubClient from sdlc_hub.core.client import HubClient
from jira_hub.core.modules import CommandHooks, Module from sdlc_hub.core.modules import CommandHooks, Module
from jira_hub.modules.jira import MODULE as JIRA_MODULE from sdlc_hub.modules.jira import MODULE as JIRA_MODULE
from jira_hub.modules.jira import project_fields from sdlc_hub.modules.jira import project_fields
from jira_hub.modules.jira import projects as jira_projects from sdlc_hub.modules.jira import projects as jira_projects
@pytest.fixture(autouse=True)
def _isolated_home(monkeypatch, tmp_path_factory):
"""HOME — во временный каталог: на результат не влияют реальные ~/.local/share/{jira,sdlc}-hub разработчика."""
monkeypatch.setenv("HOME", str(tmp_path_factory.mktemp("home")))
@pytest.fixture @pytest.fixture

View File

@ -134,7 +134,7 @@
"name": "projects" "name": "projects"
}, },
{ {
"description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.", "description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером sdlc-wiki.",
"inputSchema": { "inputSchema": {
"properties": { "properties": {
"label": { "label": {

View File

@ -3,8 +3,8 @@ import os
import pytest import pytest
from jira_hub.core import api, cli, db, queue, release, settings from sdlc_hub.core import api, cli, db, queue, release, settings
from jira_hub.core.client import HubError from sdlc_hub.core.client import HubError
from v6db import make_v6_db from v6db import make_v6_db
@ -91,7 +91,7 @@ def test_agent_query_cannot_be_forged(hub_server_two_modules):
def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys): def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path)) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path))
assert cli.main(["tokens"]) == 0 assert cli.main(["tokens"]) == 0
lines = capsys.readouterr().out.splitlines() lines = capsys.readouterr().out.splitlines()
assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ") assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ")
@ -223,7 +223,7 @@ def test_release_dirs_are_private(tmp_path):
def test_publish_unknown_module(tmp_path, monkeypatch, capsys): def test_publish_unknown_module(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
src = tmp_path / "agent.py" src = tmp_path / "agent.py"
src.write_text("AGENT_VERSION = 1\n", encoding="utf-8") src.write_text("AGENT_VERSION = 1\n", encoding="utf-8")
assert cli.main(["agent", "publish", "--module", "nope", str(src)]) == 2 assert cli.main(["agent", "publish", "--module", "nope", str(src)]) == 2
@ -231,7 +231,7 @@ def test_publish_unknown_module(tmp_path, monkeypatch, capsys):
def test_publish_module_option(tmp_path, monkeypatch, capsys): def test_publish_module_option(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
src = tmp_path / "agent.py" src = tmp_path / "agent.py"
src.write_text("AGENT_VERSION = 1\n", encoding="utf-8") src.write_text("AGENT_VERSION = 1\n", encoding="utf-8")
assert cli.main(["agent", "publish", "--module", "jira", str(src)]) == 0 assert cli.main(["agent", "publish", "--module", "jira", str(src)]) == 0
@ -293,7 +293,7 @@ def test_v14_agent_over_http_on_v6_base(tmp_path):
import threading import threading
from pathlib import Path from pathlib import Path
from jira_hub.core.client import HubClient from sdlc_hub.core.client import HubClient
make_v6_db(tmp_path / "hub.sqlite") make_v6_db(tmp_path / "hub.sqlite")
old = tmp_path / "release" # прежняя раскладка v14: release/agent.py old = tmp_path / "release" # прежняя раскладка v14: release/agent.py

View File

@ -11,10 +11,10 @@ from datetime import timedelta
import pytest import pytest
from jira_hub.core import api, db, files, queue, release, settings from sdlc_hub.core import api, db, files, queue, release, settings
from jira_hub.core.client import HubClient, HubError from sdlc_hub.core.client import HubClient, HubError
from jira_hub.modules.jira import api as jira_api from sdlc_hub.modules.jira import api as jira_api
from jira_hub.modules.jira.commands import CommandError from sdlc_hub.modules.jira.commands import CommandError
@pytest.fixture @pytest.fixture
@ -158,7 +158,7 @@ def test_backup_created_on_first_write(hub, tmp_path):
def test_client_unavailable(): def test_client_unavailable():
from jira_hub.core.client import HubUnavailable from sdlc_hub.core.client import HubUnavailable
with pytest.raises(HubUnavailable): with pytest.raises(HubUnavailable):
HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues") HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues")
@ -1059,7 +1059,7 @@ ATT_NAME = "скрин 1.png"
def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME): def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME):
conn = hub["hub"].conn() conn = hub["hub"].conn()
try: try:
from jira_hub.modules.jira import mirror from sdlc_hub.modules.jira import mirror
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], []) {"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
finally: finally:

View File

@ -2,8 +2,8 @@ import json
import pytest import pytest
from jira_hub.core import db, files from sdlc_hub.core import db, files
from jira_hub.modules.jira import attachments, mirror from sdlc_hub.modules.jira import attachments, mirror
def test_fmt_size(): def test_fmt_size():

View File

@ -5,8 +5,8 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from jira_hub.core import api, db, settings from sdlc_hub.core import api, db, settings
from jira_hub.modules.jira import mcp as jira_mcp from sdlc_hub.modules.jira import mcp as jira_mcp
FIXTURES = Path(__file__).parent / "fixtures" / "baseline" FIXTURES = Path(__file__).parent / "fixtures" / "baseline"
@ -27,7 +27,7 @@ def test_mcp_tools():
def test_mcp_wiki_tools(): def test_mcp_wiki_tools():
from jira_hub.modules.wiki import mcp as wiki_mcp from sdlc_hub.modules.wiki import mcp as wiki_mcp
tools = asyncio.run(wiki_mcp.mcp.list_tools()) tools = asyncio.run(wiki_mcp.mcp.list_tools())
_check("mcp_wiki_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema} _check("mcp_wiki_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
for t in tools]) for t in tools])

View File

@ -2,13 +2,15 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from jira_hub.core import api, cli, paths, release, settings import pytest
from sdlc_hub.core import api, cli, paths, release, settings
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db): def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
assert cli.main(["init", "--config", str(FIXTURE)]) == 0 assert cli.main(["init", "--config", str(FIXTURE)]) == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "transitions_map" in out and "агент jira: " in out assert "transitions_map" in out and "агент jira: " in out
@ -31,7 +33,7 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db):
def test_agent_publish(tmp_path, monkeypatch, capsys): def test_agent_publish(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
src = tmp_path / "agent.py" src = tmp_path / "agent.py"
src.write_text("AGENT_VERSION = 6\n", encoding="utf-8") src.write_text("AGENT_VERSION = 6\n", encoding="utf-8")
assert cli.main(["agent", "publish", str(src)]) == 0 assert cli.main(["agent", "publish", str(src)]) == 0
@ -44,7 +46,7 @@ def test_agent_publish(tmp_path, monkeypatch, capsys):
def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
"""Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25).""" """Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25)."""
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
src = tmp_path / "agent.py" src = tmp_path / "agent.py"
src.write_text("AGENT_VERSION = 8\n", encoding="utf-8") src.write_text("AGENT_VERSION = 8\n", encoding="utf-8")
assert cli.main(["agent", "publish", str(src)]) == 0 assert cli.main(["agent", "publish", str(src)]) == 0
@ -66,23 +68,25 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
assert sorted(p.name for p in release.module_dir(paths.release_root(), "jira").iterdir()) == ["agent.py", "manifest.json"] assert sorted(p.name for p in release.module_dir(paths.release_root(), "jira").iterdir()) == ["agent.py", "manifest.json"]
def test_mcp_default_module(monkeypatch): def test_mcp_module_required(monkeypatch, capsys):
from jira_hub.core import mcp as core_mcp from sdlc_hub.core import mcp as core_mcp
from jira_hub.modules.jira import mcp as jira_mcp from sdlc_hub.modules.jira import mcp as jira_mcp
seen = [] seen = []
monkeypatch.setattr(core_mcp, "PROJECT", None) monkeypatch.setattr(core_mcp, "PROJECT", None) # run() пишет глобал; monkeypatch вернёт прежнее значение
monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append((transport, core_mcp.PROJECT))) monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(core_mcp.PROJECT))
assert cli.main(["mcp", "--project", "AWPCA"]) == 0 assert cli.main(["mcp", "--project", "AWPCA"]) == 2
err = capsys.readouterr().err
assert "укажите модуль: sdlc-hub mcp <модуль>" in err and "jira, wiki" in err
assert seen == []
assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0 assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0
assert seen == [("stdio", "AWPCA"), ("stdio", "AWPCA")] assert seen == ["AWPCA"]
# без --project сессия не привязана к проекту
monkeypatch.setattr(core_mcp, "PROJECT", "STALE") monkeypatch.setattr(core_mcp, "PROJECT", "STALE")
assert cli.main(["mcp"]) == 0 assert cli.main(["mcp", "jira"]) == 0
assert seen[-1] == ("stdio", None) assert seen[-1] is None
def test_mcp_unknown_module(monkeypatch, capsys): def test_mcp_unknown_module(monkeypatch, capsys):
from jira_hub.modules.jira import mcp as jira_mcp from sdlc_hub.modules.jira import mcp as jira_mcp
seen = [] seen = []
monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(transport)) monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(transport))
assert cli.main(["mcp", "nope"]) == 2 assert cli.main(["mcp", "nope"]) == 2
@ -93,7 +97,7 @@ def test_mcp_unknown_module(monkeypatch, capsys):
def test_agent_publish_migrates_old_layout(tmp_path, monkeypatch, capsys): def test_agent_publish_migrates_old_layout(tmp_path, monkeypatch, capsys):
"""M-3: корневые agent.py/manifest.json прежней раскладки переезжают в release/jira/ до проверки «только вверх».""" """M-3: корневые agent.py/manifest.json прежней раскладки переезжают в release/jira/ до проверки «только вверх»."""
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path)) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path))
rel = tmp_path / "release" rel = tmp_path / "release"
rel.mkdir() rel.mkdir()
old = tmp_path / "old.py" old = tmp_path / "old.py"
@ -104,3 +108,34 @@ def test_agent_publish_migrates_old_layout(tmp_path, monkeypatch, capsys):
assert cli.main(["agent", "publish", str(new)]) == 1 # v13 < v14: «только вверх» видит прежнюю публикацию assert cli.main(["agent", "publish", str(new)]) == 1 # v13 < v14: «только вверх» видит прежнюю публикацию
assert not (rel / "agent.py").exists() and not (rel / "manifest.json").exists() assert not (rel / "agent.py").exists() and not (rel / "manifest.json").exists()
assert json.loads((rel / "jira" / "manifest.json").read_text(encoding="utf-8"))["version"] == 14 assert json.loads((rel / "jira" / "manifest.json").read_text(encoding="utf-8"))["version"] == 14
def _homes(monkeypatch, tmp_path, legacy=True, new=False):
monkeypatch.delenv("SDLC_HUB_HOME", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
share = tmp_path / ".local" / "share"
if legacy: (share / "jira-hub").mkdir(parents=True)
if new: (share / "sdlc-hub").mkdir(parents=True)
return share
@pytest.mark.parametrize("argv", [["serve"], ["tokens"], ["mcp", "jira"], ["agent", "publish", "x.py"]])
def test_legacy_home_refused(monkeypatch, tmp_path, capsys, argv):
share = _homes(monkeypatch, tmp_path)
assert cli.main(argv) == 2
err = capsys.readouterr().err
assert "~/.local/share/jira-hub" in err and "scripts/cutover.sh" in err
assert not (share / "sdlc-hub").exists()
assert sorted(p.name for p in (share / "jira-hub").iterdir()) == []
def test_both_homes_warn(monkeypatch, tmp_path, capsys):
_homes(monkeypatch, tmp_path, new=True)
cli.main(["tokens"])
assert "прежний каталог ~/.local/share/jira-hub можно удалить" in capsys.readouterr().err
def test_env_home_skips_legacy_check(monkeypatch, tmp_path, capsys):
_homes(monkeypatch, tmp_path)
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
assert cli.main(["tokens"]) == 0

View File

@ -1,7 +1,7 @@
import pytest import pytest
from jira_hub.modules.jira import commands, mirror from sdlc_hub.modules.jira import commands, mirror
from jira_hub.modules.jira.commands import CommandError from sdlc_hub.modules.jira.commands import CommandError
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')" NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"

384
tests/test_cutover.py Normal file
View File

@ -0,0 +1,384 @@
"""Скрипт переключения живой установки: только сухой прогон, отказы preflight и чистые функции."""
import importlib.util
import json
import os
import plistlib
import sys
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "cutover.py"
_spec = importlib.util.spec_from_file_location("cutover", SCRIPT)
cutover = importlib.util.module_from_spec(_spec)
sys.modules["cutover"] = cutover
_spec.loader.exec_module(cutover)
GIT_ENV = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
}
def git(*args, cwd):
subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True, env={**os.environ, **GIT_ENV})
def snapshot(root: Path):
out = {}
for p in sorted(root.rglob("*")):
st = p.stat()
out[str(p)] = (st.st_mtime_ns, st.st_size)
return out
@pytest.fixture
def env(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
bridge = tmp_path / "bridge"
bare = tmp_path / "remote.git"
bare.mkdir()
git("init", "--bare", "-b", "main", cwd=bare)
old = bridge / "jira-hub"
old.mkdir(parents=True)
git("init", "-b", "main", cwd=old)
(old / "a.txt").write_text("x")
git("add", ".", cwd=old)
git("commit", "-m", "init", cwd=old)
git("remote", "add", "origin", str(bare), cwd=old)
git("push", "-u", "origin", "main", cwd=old)
data = tmp_path / "share"
(data / "jira-hub").mkdir(parents=True)
(data / "jira-hub" / "hub.sqlite").write_text("db")
proj = tmp_path / "proj"
proj.mkdir()
(proj / ".mcp.json").write_text(json.dumps({"mcpServers": {"jira-hub": {"command": "uv", "args": []}}}))
for k, v in {
"CUTOVER_BRIDGE": bridge, "CUTOVER_DATA_ROOT": data, "CUTOVER_LAUNCH_AGENTS": tmp_path / "la",
"CUTOVER_LOGS": tmp_path / "logs", "CUTOVER_REMOTE": bare, "CUTOVER_PROJECTS": proj,
}.items():
monkeypatch.setenv(k, str(v))
monkeypatch.setattr(cutover.shutil, "which", lambda name: "/usr/bin/" + name)
monkeypatch.setattr(cutover, "_pgrep_mcp", lambda: [])
monkeypatch.setenv("CUTOVER_PROJECTS_ROOT", str(tmp_path / "projroot"))
return {"tmp": tmp_path, "bridge": bridge, "old": old, "data": data, "bare": bare, "proj": proj}
def test_rewrite_mcp_json(tmp_path):
p = tmp_path / ".mcp.json"
old = "/old/jira-hub"
p.write_text(json.dumps({"mcpServers": {
"jira-hub": {"command": "uv", "args": ["run", "--project", old, "jira-hub", "mcp", "--project", "AWPCA"]},
"jira-hub-wiki": {"command": "uv", "args": ["run", "--project", old, "jira-hub", "mcp", "wiki", "--project", "AWPCA"]},
"other": {"command": "x", "args": ["1"]},
}}))
co = Path("/new/sdlc-hub")
assert cutover.rewrite_mcp_json(p, co) is True
servers = json.loads(p.read_text())["mcpServers"]
assert list(servers) == ["sdlc-jira", "sdlc-wiki", "other"]
assert servers["sdlc-jira"]["args"] == ["run", "--project", str(co), "sdlc-hub", "mcp", "jira", "--project", "AWPCA"]
assert servers["sdlc-wiki"]["args"] == ["run", "--project", str(co), "sdlc-hub", "mcp", "wiki", "--project", "AWPCA"]
assert servers["other"] == {"command": "x", "args": ["1"]}
assert cutover.rewrite_mcp_json(p, co) is False
def test_rewrite_text_settings(tmp_path):
p = tmp_path / "settings.json"
p.write_text(json.dumps({"enabledMcpjsonServers": ["jira-hub", "jira-hub-wiki"],
"permissions": {"allow": ["mcp__jira-hub__issue", "mcp__jira-hub-wiki__page"]}}))
assert cutover.rewrite_text(p) == []
d = json.loads(p.read_text())
assert d["enabledMcpjsonServers"] == ["sdlc-jira", "sdlc-wiki"]
assert d["permissions"]["allow"] == ["mcp__sdlc-jira__issue", "mcp__sdlc-wiki__page"]
def test_rewrite_text_reports_leftovers(tmp_path):
p = tmp_path / "CLAUDE.md"
p.write_text("Мост Jira (aep/jira-hub, aep/jira-agent) работает через MCP `jira-hub` (user scope)\nкаталог jira-hub\n")
left = cutover.rewrite_text(p)
text = p.read_text()
assert "aep/sdlc-hub" in text and "MCP `sdlc-jira`" in text
assert left == ["каталог jira-hub"]
def test_backup(tmp_path):
p = tmp_path / "f.json"
p.write_text("a")
b = cutover.backup(p)
assert b == tmp_path / "f.json.pre-sdlc" and b.read_text() == "a"
def test_plist_text(tmp_path):
log = tmp_path / "sdlc-hub.log"
d = plistlib.loads(cutover.plist_text(tmp_path / "sdlc-hub", log, "/opt/homebrew/bin/uv").encode())
assert d["Label"] == "ru.aepif.sdlc-hub"
assert d["ProgramArguments"][-2:] == ["sdlc-hub", "serve"]
assert d["ProgramArguments"][0] == "/opt/homebrew/bin/uv"
assert d["StandardOutPath"] == d["StandardErrorPath"] == str(log)
assert d["KeepAlive"] is True
def test_dry_run_changes_nothing(env, capsys):
before = snapshot(env["tmp"])
assert cutover.main([]) == 0
assert snapshot(env["tmp"]) == before
assert "2." in capsys.readouterr().out
def test_apply_refuses_without_remote(env, capsys):
os.environ["CUTOVER_REMOTE"] = str(env["tmp"] / "nope.git")
before = snapshot(env["tmp"])
assert cutover.main(["--apply"]) == 1
assert "aep/sdlc-hub" in capsys.readouterr().out
assert snapshot(env["tmp"]) == before
def test_apply_refuses_when_target_exists(env, capsys):
(env["data"] / "sdlc-hub").mkdir()
before = snapshot(env["tmp"])
assert cutover.main(["--apply"]) == 1
assert "уже существует" in capsys.readouterr().out
assert snapshot(env["tmp"]) == before
def test_dirty_checkout_refused(env):
(env["old"] / "dirty.txt").write_text("x")
probs = cutover.preflight(cutover.Config.from_env())
assert any("не чист" in s for s in probs)
def test_preflight_clean(env):
assert cutover.preflight(cutover.Config.from_env()) == []
def test_preflight_reports_missing_tool(env, monkeypatch):
monkeypatch.setattr(cutover.shutil, "which", lambda name: None if name in ("claude", "make") else "/x/" + name)
probs = cutover.preflight(cutover.Config.from_env())
assert any("claude" in s for s in probs) and any("make" in s for s in probs)
assert not any("uv " in s for s in probs)
class _Fake:
def __init__(self, rc_by_cmd=None):
self.calls = []
self.rc_by_cmd = rc_by_cmd or {}
def __call__(self, cmd, **kw):
self.calls.append((list(cmd), kw))
rc = self.rc_by_cmd.get(cmd[2] if len(cmd) > 2 else "", 0)
r = subprocess.CompletedProcess(cmd, rc)
if kw.get("check") and rc:
raise subprocess.CalledProcessError(rc, cmd)
return r
def test_step7_add_before_remove_and_remove_not_fatal(env, monkeypatch, capsys):
fake = _Fake({"remove": 1})
monkeypatch.setattr(cutover.subprocess, "run", fake)
cfg = cutover.Config.from_env()
desc, fn = cutover.steps(cfg)[-1]
fn()
cmds = [c for c, _ in fake.calls]
assert cmds[0][:3] == ["claude", "mcp", "add"] and "sdlc-jira" in cmds[0]
assert cmds[1][:3] == ["claude", "mcp", "remove"]
assert fake.calls[1][1].get("check") is False
assert "ПРЕДУПРЕЖДЕНИЕ" in capsys.readouterr().out
def test_step2_fails_if_port_still_open(env, monkeypatch, capsys):
monkeypatch.setattr(cutover.subprocess, "run", _Fake())
monkeypatch.setattr(cutover, "_port_open", lambda port: True)
t = [0.0]
monkeypatch.setattr(cutover, "_clock", lambda: t[0])
monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d))
monkeypatch.setattr(cutover, "preflight", lambda cfg: [])
assert cutover.main(["--apply"]) == 1
out = capsys.readouterr().out
assert "порт" in out and "остановлено на шаге 2" in out
assert "мог быть выгружен" in out and "переименован" in out
assert "ничего не изменено" not in out
assert "launchctl bootstrap gui/%d %s" % (os.getuid(), env["tmp"] / "la" / "ru.aepif.jira-hub.plist") in out
assert t[0] >= 10
def test_step2_waits_for_port_to_close(env, monkeypatch):
monkeypatch.setattr(cutover.subprocess, "run", _Fake())
answers = iter([True, True, True, False])
monkeypatch.setattr(cutover, "_port_open", lambda port: next(answers))
t = [0.0]
monkeypatch.setattr(cutover, "_clock", lambda: t[0])
monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d))
cutover.steps(cutover.Config.from_env())[0][1]()
assert 0 < t[0] < 10
def test_failure_prints_state_and_rollback(env, monkeypatch, capsys):
def boom():
raise RuntimeError("сломалось")
plan = [("2. a", lambda: None), ("3. b", lambda: None), ("4. c", boom)]
monkeypatch.setattr(cutover, "preflight", lambda cfg: [])
monkeypatch.setattr(cutover, "steps", lambda cfg, leftovers=None, backups=None: plan)
assert cutover.main(["--apply"]) == 1
out = capsys.readouterr().out
assert "остановлено на шаге 4: сломалось" in out
assert "2. a" in out and "3. b" in out
assert "хаб ru.aepif.jira-hub остановлен" in out and "каталог данных перенесён" in out
assert "Откат" in out and "README" in out
def test_preflight_ignores_untracked_old_build(env):
(env["old"] / "src" / "sdlc_hub").mkdir(parents=True) # как в живом checkout: src/ уже отслеживается
(env["old"] / "src" / "sdlc_hub" / "__init__.py").write_text("")
git("add", ".", cwd=env["old"])
git("commit", "-m", "pkg", cwd=env["old"])
git("push", "origin", "main", cwd=env["old"])
d = env["old"] / "src" / "jira_hub" / "web" / "app"
d.mkdir(parents=True)
(d / "x.js").write_text("x")
assert cutover.preflight(cutover.Config.from_env()) == []
def test_preflight_tracked_or_other_untracked_is_dirty(env):
d = env["old"] / "src" / "jira_hub"
d.mkdir(parents=True)
(d / "t.py").write_text("x")
git("add", ".", cwd=env["old"])
git("commit", "-m", "tracked", cwd=env["old"])
git("push", "origin", "main", cwd=env["old"])
(d / "new.py").write_text("y")
assert any("не чист" in s for s in cutover.preflight(cutover.Config.from_env()))
(d / "new.py").unlink()
(env["old"] / "other.txt").write_text("z")
assert any("не чист" in s for s in cutover.preflight(cutover.Config.from_env()))
@pytest.mark.parametrize("origin,expected", [
("ssh://git@git.aepif.ru:2222/aep/jira-hub.git", "ssh://git@git.aepif.ru:2222/aep/sdlc-hub.git"),
("https://git.aepif.ru/aep/jira-hub.git", "https://git.aepif.ru/aep/sdlc-hub.git"),
("ssh://git@git.aepif.ru:2222/aep/sdlc-hub.git", "ssh://git@git.aepif.ru:2222/aep/sdlc-hub.git"), # origin уже сменён
])
def test_derive_remote(origin, expected):
assert cutover.derive_remote(origin) == expected
def test_target_remote_from_origin_and_problem(env, monkeypatch):
monkeypatch.delenv("CUTOVER_REMOTE")
cfg = cutover.Config.from_env()
assert cfg.remote == ""
url, prob = cutover.target_remote(cfg)
assert url == "" and "CUTOVER_REMOTE" in prob # origin фикстуры - путь без /aep/jira-hub
git("remote", "set-url", "origin", "ssh://git@h:2222/aep/jira-hub.git", cwd=env["old"])
assert cutover.target_remote(cfg) == ("ssh://git@h:2222/aep/sdlc-hub.git", "")
def test_consumers_autodiscovery(env):
root = env["tmp"] / "projroot"
sfm = root / "sfm"
sfm.mkdir(parents=True)
(sfm / ".mcp.json").write_text('{"mcpServers": {"jira-hub": {}}}')
other = root / "other"
other.mkdir()
(other / ".mcp.json").write_text('{"mcpServers": {}}')
cfg = cutover.Config.from_env()
assert cutover.consumer_projects(cfg) == [env["proj"], sfm]
def test_dry_run_lists_consumers_and_pgrep_warning(env, monkeypatch, capsys):
sfm = env["tmp"] / "projroot" / "sfm"
sfm.mkdir(parents=True)
(sfm / ".mcp.json").write_text('{"mcpServers": {"jira-hub": {}}}')
monkeypatch.setattr(cutover, "_pgrep_mcp", lambda: ["123 uv run jira-hub mcp"])
before = snapshot(env["tmp"])
assert cutover.main([]) == 0
out = capsys.readouterr().out
assert str(sfm) in out and "123 uv run jira-hub mcp" in out and "ПРЕДУПРЕЖДЕНИЕ" in out
assert snapshot(env["tmp"]) == before
def test_rewrite_mcp_json_reports_nonstandard(tmp_path):
p = tmp_path / ".mcp.json"
p.write_text(json.dumps({"mcpServers": {
"jira-hub": {"command": "python", "args": ["x"]},
"jira-hub-wiki": {"command": "uv", "args": ["run", "foo"]},
}}))
un = []
assert cutover.rewrite_mcp_json(p, Path("/c"), un) is False
assert len(un) == 2 and all(str(p) in m for m in un)
assert list(json.loads(p.read_text())["mcpServers"]) == ["jira-hub", "jira-hub-wiki"]
def test_print_tail_warns_about_recreated_data_dir(env, capsys):
cfg = cutover.Config.from_env()
(env["data"] / "jira-hub").rename(env["data"] / "sdlc-hub")
cutover._print_tail(cfg, ["3. x"], [], [])
assert "появился заново" not in capsys.readouterr().out
(env["data"] / "jira-hub").mkdir()
cutover._print_tail(cfg, ["3. x"], [], [])
assert "появился заново" in capsys.readouterr().out
def test_step2_renames_plist(env, monkeypatch):
monkeypatch.setattr(cutover.subprocess, "run", _Fake())
monkeypatch.setattr(cutover, "_port_open", lambda port: False)
la = env["tmp"] / "la"
la.mkdir()
(la / "ru.aepif.jira-hub.plist").write_text("p")
cutover.steps(cutover.Config.from_env())[0][1]()
assert not (la / "ru.aepif.jira-hub.plist").exists()
assert (la / "ru.aepif.jira-hub.plist.pre-sdlc").read_text() == "p"
def test_step3_moves_data(env):
cutover.steps(cutover.Config.from_env())[1][1]()
assert (env["data"] / "sdlc-hub" / "hub.sqlite").read_text() == "db"
assert not (env["data"] / "jira-hub").exists()
def test_step4_moves_checkout_and_sets_origin(env, monkeypatch):
fake = _Fake()
monkeypatch.setattr(cutover.subprocess, "run", fake)
monkeypatch.setattr(cutover, "_git", lambda repo, *a: subprocess.CompletedProcess(a, 0, stdout="", stderr=""))
(env["old"] / "src" / "jira_hub" / "web").mkdir(parents=True)
(env["old"] / ".venv").mkdir()
cutover.steps(cutover.Config.from_env())[2][1]()
new = env["bridge"] / "sdlc-hub"
assert new.is_dir() and not env["old"].exists()
assert not (new / "src" / "jira_hub").exists() and not (new / ".venv").exists()
cmds = [c for c, _ in fake.calls]
assert ["git", "-C", str(new), "remote", "set-url", "origin", str(env["bare"])] in cmds
assert ["uv", "sync"] in cmds and ["make", "web"] in cmds
def test_step6_rewrites_consumers_with_backups(env):
proj = env["proj"]
(proj / ".mcp.json").write_text(json.dumps({"mcpServers": {"jira-hub": {
"command": "uv", "args": ["run", "--project", "/o", "jira-hub", "mcp", "--project", "AWPCA"]}}}))
(proj / ".claude" / "skills" / "s").mkdir(parents=True)
(proj / ".claude" / "settings.local.json").write_text('{"enabledMcpjsonServers": ["jira-hub"]}')
(proj / ".claude" / "skills" / "s" / "SKILL.md").write_text("mcp__jira-hub__issue и каталог jira-hub\n")
(proj / "CLAUDE.md").write_text("без упоминаний\n")
left, backs = [], []
cutover.steps(cutover.Config.from_env(), left, backs)[4][1]()
names = sorted(b.name for b in backs)
assert names == [".mcp.json.pre-sdlc", "SKILL.md.pre-sdlc", "settings.local.json.pre-sdlc"]
assert "sdlc-jira" in (proj / ".mcp.json").read_text()
assert "sdlc-jira" in (proj / ".claude" / "settings.local.json").read_text()
assert any("каталог jira-hub" in ln for ln in left)
assert not (proj / "CLAUDE.md.pre-sdlc").exists()
def test_preflight_checks_head_against_new_remote(env):
"""Gitea не перенаправляет git по SSH после переименования: старый origin недоступен, сверка — с новым адресом."""
git("remote", "set-url", "origin", str(env["tmp"] / "gone.git"), cwd=env["old"])
problems = cutover.preflight(cutover.Config.from_env())
assert not any("origin/main" in s or "HEAD не совпадает" in s for s in problems), problems
other = env["tmp"] / "other"
git("clone", str(env["bare"]), str(other), cwd=env["tmp"])
(other / "b.txt").write_text("y")
git("add", ".", cwd=other)
git("commit", "-m", "ahead", cwd=other)
git("push", "origin", "main", cwd=other)
assert any("HEAD не совпадает" in s for s in cutover.preflight(cutover.Config.from_env()))

View File

@ -7,8 +7,8 @@ import threading
import pytest import pytest
from jira_hub.core import api, backup, db, paths, projects, queue, settings from sdlc_hub.core import api, backup, db, paths, projects, queue, settings
from jira_hub.modules.jira import history from sdlc_hub.modules.jira import history
def test_connect_creates_schema(open_db): def test_connect_creates_schema(open_db):
@ -155,7 +155,7 @@ def test_data_dir_is_private(tmp_path):
def test_existing_backups_dir_made_private_on_start(tmp_path): def test_existing_backups_dir_made_private_on_start(tmp_path):
from jira_hub.core import api from sdlc_hub.core import api
backups = tmp_path / "backups" backups = tmp_path / "backups"
backups.mkdir() backups.mkdir()
backups.chmod(0o755) backups.chmod(0o755)

View File

@ -2,8 +2,8 @@ import threading
import pytest import pytest
from jira_hub.core import api, settings from sdlc_hub.core import api, settings
from jira_hub.core.client import HubClient, HubError from sdlc_hub.core.client import HubClient, HubError
@pytest.fixture @pytest.fixture

View File

@ -7,7 +7,7 @@ import time
import pytest import pytest
from jira_hub.core import db, files, settings from sdlc_hub.core import db, files, settings
ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf… ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf…
DAY = 86400 DAY = 86400

View File

@ -1,6 +1,6 @@
import pytest import pytest
from jira_hub.modules.jira import history from sdlc_hub.modules.jira import history
ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр", ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр",
"field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001",
@ -29,7 +29,7 @@ def test_record_keeps_first_version(conn):
def test_record_uses_field_settings(conn): def test_record_uses_field_settings(conn):
from jira_hub.core import settings from sdlc_hub.core import settings
settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"}) settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"})
history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}]) history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}])
assert conn.execute("SELECT field FROM issue_change").fetchone()["field"] == "planned_end" assert conn.execute("SELECT field FROM issue_change").fetchone()["field"] == "planned_end"
@ -92,7 +92,7 @@ def test_feed_closed_in_two_hours(conn): # сце
def test_feed_comment(conn): # «Комментарий в ленте» def test_feed_comment(conn): # «Комментарий в ленте»
from jira_hub.modules.jira import mirror from sdlc_hub.modules.jira import mirror
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [ mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [
{"id": "7", "author": "Иванов", "body": "Готово к проверке", "created": "2026-10-22T13:30:00.000+0300"}]}], []) {"id": "7", "author": "Иванов", "body": "Готово к проверке", "created": "2026-10-22T13:30:00.000+0300"}]}], [])
got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", history.DEFAULT_KINDS) got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", history.DEFAULT_KINDS)
@ -163,7 +163,7 @@ def test_parse_kinds_unknown():
def test_issue_changes(conn): # «Задача с историей» def test_issue_changes(conn): # «Задача с историей»
from jira_hub.modules.jira import mirror from sdlc_hub.modules.jira import mirror
_issue(conn, "X", ["AWPCA"]) _issue(conn, "X", ["AWPCA"])
_change(conn, "X", "1", "2026-10-21T11:00:00Z", "status", "a", "b") _change(conn, "X", "1", "2026-10-21T11:00:00Z", "status", "a", "b")
_change(conn, "X", "2", "2026-10-22T11:00:00Z", "description", "c", "d", field_id="description") _change(conn, "X", "2", "2026-10-22T11:00:00Z", "description", "c", "d", field_id="description")

View File

@ -2,8 +2,8 @@ import csv
import pytest import pytest
from jira_hub.core import settings from sdlc_hub.core import settings
from jira_hub.modules.jira import importer, mirror from sdlc_hub.modules.jira import importer, mirror
@pytest.fixture @pytest.fixture

View File

@ -3,21 +3,34 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import jira_hub import sdlc_hub
def test_fresh_imports(): def test_fresh_imports():
r = subprocess.run( r = subprocess.run(
[sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp, jira_hub.modules.jira.mcp, jira_hub.cli"], [sys.executable, "-c", "import sdlc_hub.core.api, sdlc_hub.core.mcp, sdlc_hub.modules.jira.mcp, sdlc_hub.cli"],
capture_output=True, text=True) capture_output=True, text=True)
assert r.returncode == 0, r.stderr assert r.returncode == 0, r.stderr
def test_web_app_path(): def test_web_app_path():
from jira_hub.core import api from sdlc_hub.core import api
assert api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app" assert api.WEB_APP == Path(sdlc_hub.__file__).parent / "web" / "app"
def test_hub_root(): def test_hub_root():
from jira_hub.core import mcp from sdlc_hub.core import mcp
assert (mcp.HUB_ROOT / "pyproject.toml").is_file() assert (mcp.HUB_ROOT / "pyproject.toml").is_file()
def test_default_home(monkeypatch, tmp_path):
from sdlc_hub.core import paths
monkeypatch.delenv("SDLC_HUB_HOME", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub"
def test_env_home(monkeypatch, tmp_path):
from sdlc_hub.core import paths
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
assert paths.home() == tmp_path / "x"

View File

@ -9,11 +9,11 @@ from types import SimpleNamespace
import pytest import pytest
from jira_hub.core import api, files, settings from sdlc_hub.core import api, files, settings
from jira_hub.core import mcp as core_mcp from sdlc_hub.core import mcp as core_mcp
from jira_hub.modules.jira import mcp as jmcp from sdlc_hub.modules.jira import mcp as jmcp
from jira_hub.modules.jira import commands from sdlc_hub.modules.jira import commands
from jira_hub.core.client import HubClient from sdlc_hub.core.client import HubClient
@pytest.fixture @pytest.fixture
@ -503,16 +503,20 @@ def test_project_create(live, monkeypatch): # сце
out = jmcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120) out = jmcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out
frag = json.loads(out[out.index("{"):]) frag = json.loads(out[out.index("{"):])
assert frag == core_mcp.mcp_json("STOCKS_DRRK") assert frag == core_mcp.mcp_json("STOCKS_DRRK", "jira")
assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"] assert frag["mcpServers"]["sdlc-jira"]["args"][-2:] == ["--project", "STOCKS_DRRK"]
assert live["client"].get("/batches")["batches"][0]["author"] == "claude" assert live["client"].get("/batches")["batches"][0]["author"] == "claude"
assert jmcp.project_create("awpca", "x").startswith("ошибка хаба 409") assert jmcp.project_create("awpca", "x").startswith("ошибка хаба 409")
assert jmcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422") assert jmcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
def test_server_name():
assert jmcp.mcp.name == "sdlc-jira"
def test_hub_root_is_repo(): def test_hub_root_is_repo():
assert (core_mcp.HUB_ROOT / "pyproject.toml").is_file() assert (core_mcp.HUB_ROOT / "pyproject.toml").is_file()
assert core_mcp.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)] assert core_mcp.mcp_json("X", "jira")["mcpServers"]["sdlc-jira"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)]
def test_fmt_projects(): def test_fmt_projects():
@ -700,7 +704,7 @@ class FakeHub:
@pytest.fixture @pytest.fixture
def downloads(tmp_path, monkeypatch): def downloads(tmp_path, monkeypatch):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05) monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
return tmp_path / "home" / "downloads" return tmp_path / "home" / "downloads"
@ -819,7 +823,7 @@ def test_attachment_failed(downloads, monkeypatch):
def test_attachment_413(downloads, monkeypatch): def test_attachment_413(downloads, monkeypatch):
from jira_hub.core.client import HubError from sdlc_hub.core.client import HubError
att = _att("5", "запись.mp4", 26214400) att = _att("5", "запись.mp4", 26214400)
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
_use(monkeypatch, hub) _use(monkeypatch, hub)
@ -828,7 +832,7 @@ def test_attachment_413(downloads, monkeypatch):
def test_attachment_409_passes_hub_text(downloads, monkeypatch): def test_attachment_409_passes_hub_text(downloads, monkeypatch):
from jira_hub.core.client import HubError from sdlc_hub.core.client import HubError
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]), hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]),
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14")) fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
_use(monkeypatch, hub) _use(monkeypatch, hub)

View File

@ -1,7 +1,7 @@
import pytest import pytest
from jira_hub.core import db from sdlc_hub.core import db
from jira_hub.modules.jira import mirror from sdlc_hub.modules.jira import mirror
@pytest.fixture @pytest.fixture

View File

@ -1,10 +1,10 @@
"""Поля проекта от модулей; доски — в модуле jira (wiki-module, решение 8, спеки modules/data-model/hub-api).""" """Поля проекта от модулей; доски — в модуле jira (wiki-module, решение 8, спеки modules/data-model/hub-api)."""
import pytest import pytest
from jira_hub.core import db, mcp as core_mcp, registry from sdlc_hub.core import db, mcp as core_mcp, registry
from jira_hub.core.client import HubError from sdlc_hub.core.client import HubError
from jira_hub.core.modules import Module, ProjectField from sdlc_hub.core.modules import Module, ProjectField
from jira_hub.modules.jira import mirror from sdlc_hub.modules.jira import mirror
from v6db import make_v6_db from v6db import make_v6_db
@ -80,7 +80,7 @@ def test_editable_field_needs_updater():
def test_migrate_v6_idempotent(tmp_path): def test_migrate_v6_idempotent(tmp_path):
from jira_hub.modules.jira import projects as jira_projects from sdlc_hub.modules.jira import projects as jira_projects
make_v6_db(tmp_path / "hub.sqlite") make_v6_db(tmp_path / "hub.sqlite")
conn = db.connect(tmp_path / "hub.sqlite") conn = db.connect(tmp_path / "hub.sqlite")
try: try:
@ -150,11 +150,12 @@ def test_field_clashes_with_core(name):
def test_mcp_json_per_module(): def test_mcp_json_per_module():
first = core_mcp.mcp_json("AWPCA")["mcpServers"] first = core_mcp.mcp_json("AWPCA", "jira")["mcpServers"]
assert list(first) == ["jira-hub"] and first["jira-hub"]["args"][-3:] == ["mcp", "--project", "AWPCA"] assert list(first) == ["sdlc-jira"] and first["sdlc-jira"]["args"][-4:] == ["mcp", "jira", "--project", "AWPCA"]
assert first["sdlc-jira"]["args"][3] == "sdlc-hub"
other = core_mcp.mcp_json("AWPCA", "wiki")["mcpServers"] other = core_mcp.mcp_json("AWPCA", "wiki")["mcpServers"]
assert list(other) == ["jira-hub-wiki"] assert list(other) == ["sdlc-wiki"]
assert other["jira-hub-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"] assert other["sdlc-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"]
def test_fmt_projects_columns(): def test_fmt_projects_columns():
@ -168,9 +169,9 @@ def two_modules(monkeypatch, open_db):
"""Реестр [jira, stub]: stub объявляет изменяемое поле root, хранит его в setting и ставит пакеты-пустышки.""" """Реестр [jira, stub]: stub объявляет изменяемое поле root, хранит его в setting и ставит пакеты-пустышки."""
import json import json
import re import re
from jira_hub.core import api, queue from sdlc_hub.core import api, queue
from jira_hub.core.http import ApiError from sdlc_hub.core.http import ApiError
from jira_hub.modules.jira import MODULE as JIRA from sdlc_hub.modules.jira import MODULE as JIRA
def check_root(v): def check_root(v):
if not isinstance(v, str) or not v: if not isinstance(v, str) or not v:

View File

@ -1,8 +1,8 @@
import pytest import pytest
from jira_hub.core import api, projects, settings from sdlc_hub.core import api, projects, settings
from jira_hub.modules.jira import mirror from sdlc_hub.modules.jira import mirror
from jira_hub.modules.jira import projects as jira_projects from sdlc_hub.modules.jira import projects as jira_projects
@pytest.fixture @pytest.fixture

View File

@ -2,12 +2,12 @@ import hashlib
import pytest import pytest
from jira_hub.core import api, db, files, queue, settings from sdlc_hub.core import api, db, files, queue, settings
from jira_hub.modules.jira import attachments, commands, mirror, refresh from sdlc_hub.modules.jira import attachments, commands, mirror, refresh
from jira_hub.modules.jira import fetch as jira_fetch from sdlc_hub.modules.jira import fetch as jira_fetch
from jira_hub.modules.jira import projects as jira_projects from sdlc_hub.modules.jira import projects as jira_projects
from jira_hub.modules.jira.commands import CommandError from sdlc_hub.modules.jira.commands import CommandError
from jira_hub.core.queue import ClaimedError from sdlc_hub.core.queue import ClaimedError
LT = {"связанные": "ASUPR_Связанные"} LT = {"связанные": "ASUPR_Связанные"}
@ -210,7 +210,7 @@ def test_no_backup_for_refresh_batch(conn):
def test_make_backup_rotates(tmp_path, open_db): def test_make_backup_rotates(tmp_path, open_db):
from jira_hub.core import backup from sdlc_hub.core import backup
conn = open_db(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
for i in range(4): for i in range(4):
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
@ -221,7 +221,7 @@ def test_make_backup_rotates(tmp_path, open_db):
def test_rotation_keeps_schema_migration_copy(tmp_path, open_db): def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
from jira_hub.core import backup from sdlc_hub.core import backup
conn = open_db(tmp_path / "hub.sqlite") conn = open_db(tmp_path / "hub.sqlite")
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0) backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
for i in range(4): for i in range(4):

View File

@ -5,10 +5,10 @@ from dataclasses import replace
import pytest import pytest
from jira_hub.core import files, queue, registry from sdlc_hub.core import files, queue, registry
from jira_hub.core.errors import CommandError from sdlc_hub.core.errors import CommandError
from jira_hub.core.modules import CommandHooks, Module from sdlc_hub.core.modules import CommandHooks, Module
from jira_hub.modules import jira from sdlc_hub.modules import jira
SHA = "a" * 64 SHA = "a" * 64

View File

@ -5,10 +5,10 @@ from pathlib import Path
import pytest import pytest
from jira_hub.core import registry from sdlc_hub.core import registry
from jira_hub.core.modules import CommandHooks, Module from sdlc_hub.core.modules import CommandHooks, Module
CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" CORE = Path(__file__).resolve().parent.parent / "src" / "sdlc_hub" / "core"
# временно: R-1, список только сокращается; после Task 6 пуст # временно: R-1, список только сокращается; после Task 6 пуст
PENDING: set[str] = set() PENDING: set[str] = set()
@ -50,7 +50,7 @@ def test_route_conflict():
def test_routes_assembled(): def test_routes_assembled():
from jira_hub.core import api from sdlc_hub.core import api
pairs = lambda routes: {(m, p) for m, p, _, _ in routes} # noqa: E731 pairs = lambda routes: {(m, p) for m, p, _, _ in routes} # noqa: E731
mods = tuple(r for m in registry.modules() for r in m.routes) mods = tuple(r for m in registry.modules() for r in m.routes)
assert pairs(api.ROUTES) == pairs(api.CORE_ROUTES) | pairs(mods) assert pairs(api.ROUTES) == pairs(api.CORE_ROUTES) | pairs(mods)
@ -65,18 +65,18 @@ def test_module_of(monkeypatch):
def _module_imports(tree, top_level_only): def _module_imports(tree, top_level_only):
"""Импорты пакета jira_hub.modules (абсолютные и относительные из core/).""" """Импорты пакета sdlc_hub.modules (абсолютные и относительные из core/)."""
found = [] found = []
for n in (tree.body if top_level_only else ast.walk(tree)): for n in (tree.body if top_level_only else ast.walk(tree)):
if isinstance(n, ast.Import): if isinstance(n, ast.Import):
names = [a.name for a in n.names] names = [a.name for a in n.names]
elif isinstance(n, ast.ImportFrom): elif isinstance(n, ast.ImportFrom):
prefix = {0: "", 1: "jira_hub.core.", 2: "jira_hub."}[n.level] prefix = {0: "", 1: "sdlc_hub.core.", 2: "sdlc_hub."}[n.level]
base = (prefix + (n.module or "")).rstrip(".") base = (prefix + (n.module or "")).rstrip(".")
names = [base] + [base + "." + a.name for a in n.names] names = [base] + [base + "." + a.name for a in n.names]
else: else:
continue continue
if any(x == "jira_hub.modules" or x.startswith("jira_hub.modules.") for x in names): if any(x == "sdlc_hub.modules" or x.startswith("sdlc_hub.modules.") for x in names):
found.append(n) found.append(n)
return found return found

View File

@ -8,8 +8,8 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from jira_hub.core import api, relay, settings from sdlc_hub.core import api, relay, settings
from jira_hub.core.client import HubClient from sdlc_hub.core.client import HubClient
@pytest.fixture @pytest.fixture

View File

@ -6,7 +6,7 @@ from pathlib import Path
import pytest import pytest
from jira_hub.core import release from sdlc_hub.core import release
def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"): def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"):

View File

@ -3,9 +3,9 @@ from pathlib import Path
import pytest import pytest
from jira_hub.core import queue, settings from sdlc_hub.core import queue, settings
from jira_hub.modules.jira import settings as jira_settings from sdlc_hub.modules.jira import settings as jira_settings
from jira_hub.modules.jira.commands import CommandError from sdlc_hub.modules.jira.commands import CommandError
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"

View File

@ -5,7 +5,7 @@ from urllib.parse import urlparse
import pytest import pytest
from jira_hub.core import api from sdlc_hub.core import api
def _get(base, path): def _get(base, path):
@ -100,7 +100,7 @@ def test_other_paths_are_not_app(app_hub):
def test_default_web_dir_is_package_build(tmp_path): def test_default_web_dir_is_package_build(tmp_path):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
assert h.web_dir == api.WEB_APP assert h.web_dir == api.WEB_APP
assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app") assert api.WEB_APP.parts[-3:] == ("sdlc_hub", "web", "app")
@pytest.mark.parametrize("path", ["/", "/links"]) @pytest.mark.parametrize("path", ["/", "/links"])

View File

@ -8,8 +8,8 @@ from urllib.parse import quote
import pytest import pytest
from jira_hub.core import files from sdlc_hub.core import files
from jira_hub.core.client import HubError from sdlc_hub.core.client import HubError
from test_wiki_convert import FILE_REF, FILE_URL, THUMB_REF, _html from test_wiki_convert import FILE_REF, FILE_URL, THUMB_REF, _html
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура

View File

@ -3,10 +3,10 @@ from datetime import timedelta
import pytest import pytest
from jira_hub.core import db, queue, settings from sdlc_hub.core import db, queue, settings
from jira_hub.core.errors import CommandError from sdlc_hub.core.errors import CommandError
from jira_hub.modules.jira import refresh from sdlc_hub.modules.jira import refresh
from jira_hub.modules.wiki import commands, settings as wiki_settings, tick from sdlc_hub.modules.wiki import commands, settings as wiki_settings, tick
@pytest.fixture @pytest.fixture

Some files were not shown because too many files have changed in this diff Show More