Переименование jira-hub → sdlc-hub (#97) #99
2
.gitignore
vendored
2
.gitignore
vendored
@ -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/
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@ -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/` на тестовом хабе.
|
||||||
|
|
||||||
|
|||||||
2
Makefile
2
Makefile
@ -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
142
README.md
@ -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.
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
|
|||||||
215
docs/superpowers/plans/2026-10-24-sdlc-rename.md
Normal file
215
docs/superpowers/plans/2026-10-24-sdlc-rename.md
Normal 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).
|
||||||
2
openspec/changes/sdlc-rename/.openspec.yaml
Normal file
2
openspec/changes/sdlc-rename/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-24
|
||||||
116
openspec/changes/sdlc-rename/design.md
Normal file
116
openspec/changes/sdlc-rename/design.md
Normal 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` |
|
||||||
42
openspec/changes/sdlc-rename/proposal.md
Normal file
42
openspec/changes/sdlc-rename/proposal.md
Normal 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/` рядом с живыми данными — провал, даже если тесты зелёные.
|
||||||
|
- Агенты на виртуалке не требуют ни перенастройки, ни нового релиза.
|
||||||
41
openspec/changes/sdlc-rename/specs/data-model/spec.md
Normal file
41
openspec/changes/sdlc-rename/specs/data-model/spec.md
Normal 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` с обеими метками
|
||||||
240
openspec/changes/sdlc-rename/specs/hub-api/spec.md
Normal file
240
openspec/changes/sdlc-rename/specs/hub-api/spec.md
Normal 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 и каталоги не тронуты
|
||||||
243
openspec/changes/sdlc-rename/specs/mcp/spec.md
Normal file
243
openspec/changes/sdlc-rename/specs/mcp/spec.md
Normal 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
|
||||||
39
openspec/changes/sdlc-rename/specs/migration/spec.md
Normal file
39
openspec/changes/sdlc-rename/specs/migration/spec.md
Normal 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:*` для неё не создаётся
|
||||||
169
openspec/changes/sdlc-rename/specs/web-app/spec.md
Normal file
169
openspec/changes/sdlc-rename/specs/web-app/spec.md
Normal 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
|
||||||
33
openspec/changes/sdlc-rename/specs/wiki/spec.md
Normal file
33
openspec/changes/sdlc-rename/specs/wiki/spec.md
Normal 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)`
|
||||||
@ -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
499
scripts/cutover.py
Normal 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
2
scripts/cutover.sh
Executable file
@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
exec python3 "$(dirname "$0")/cutover.py" "$@"
|
||||||
@ -1,2 +0,0 @@
|
|||||||
"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP."""
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@ -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
2
src/sdlc_hub/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"""sdlc-hub — хаб SDLC: ядро (проекты, очередь команд, агенты, HTTP API, веб) и модули jira и wiki."""
|
||||||
|
__version__ = "0.1.0"
|
||||||
@ -1 +1 @@
|
|||||||
from .core.cli import main # noqa: F401 точка входа jira-hub
|
from .core.cli import main # noqa: F401 точка входа sdlc-hub
|
||||||
@ -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:
|
||||||
@ -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()]
|
||||||
@ -14,7 +14,7 @@ class HubError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class HubUnavailable(Exception):
|
class HubUnavailable(Exception):
|
||||||
"""Хаб не отвечает — обычно не запущен (jira-hub serve)."""
|
"""Хаб не отвечает — обычно не запущен (sdlc-hub serve)."""
|
||||||
|
|
||||||
|
|
||||||
class HubClient:
|
class HubClient:
|
||||||
@ -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)
|
||||||
55
src/sdlc_hub/core/paths.py
Normal file
55
src/sdlc_hub/core/paths.py
Normal 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)
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
@ -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:
|
||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
384
tests/test_cutover.py
Normal 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()))
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"):
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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"])
|
||||||
|
|||||||
@ -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 — фикстура
|
||||||
|
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user