Переименование jira-hub → sdlc-hub (#97) #99
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,7 +12,7 @@ __pycache__/
|
||||
*.sqlite*
|
||||
.pytest_cache/
|
||||
.wt/
|
||||
src/jira_hub/web/app/
|
||||
src/sdlc_hub/web/app/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
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. Модуль даёт смысл: схему своих таблиц, типы
|
||||
команд, маршруты, настройки, MCP-инструменты, подкоманды. Сейчас модулей два — `jira` и `wiki`; следующие (sdlc) подключаются
|
||||
так же, строкой в `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 |
|
||||
| `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` |
|
||||
| `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib |
|
||||
@ -29,7 +29,7 @@
|
||||
| `release` | опубликованная версия агента модуля `release/<модуль>/agent.py` + `manifest.json`: `publish` (только вверх), `current`, `load`, `hint` | `paths` |
|
||||
| `client` | HTTP-клиент хаба (`HubClient`, `from_home`, `get_bytes`) | `db`, `paths`, `settings` (только `from_home`) |
|
||||
| `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/`)
|
||||
|
||||
@ -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` |
|
||||
| `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` |
|
||||
| `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/`)
|
||||
|
||||
@ -69,7 +69,7 @@
|
||||
| `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` |
|
||||
| `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` |
|
||||
| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` |
|
||||
| `mcp` | инструменты сервера `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/` (кнопка и плашка
|
||||
обновления данных из 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 с
|
||||
клиентским токеном; `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`), копия прежняя.
|
||||
- Вложение вики: `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 хаба → ответ файлом.
|
||||
- Самообновление агента: `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.
|
||||
|
||||
## Границы
|
||||
- Ядро (`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`). Меток-исключений «долг» нет.
|
||||
- Новые имена в ядре — без названий модулей. Модуль не вызывается из ядра иначе как через `registry` и контракт `Module`.
|
||||
- Копию вики (`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`.
|
||||
|
||||
## Выкатка и откат версии со схемой v7
|
||||
- Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить.
|
||||
- До kickstart не запускать MCP и CLI `jira-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её до v7, а хаб на прежнем коде (v6) с такой базой не работает (`agent_status`, токены).
|
||||
- Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить.
|
||||
- До 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`); иначе старый код увидит «публикации нет» и самообновление агента молчит.
|
||||
|
||||
## Открытые вопросы
|
||||
@ -157,4 +157,4 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
|
||||
- История страниц вики и частичная перекачка: сейчас каждая синхронизация перекачивает всё дерево, копия без истории.
|
||||
- Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `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).
|
||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||
- Хаб не создаёт команды сам: повтор после 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 в хабе не хранится.
|
||||
|
||||
## Архитектура
|
||||
- `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/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/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/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/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/wiki/` — модуль wiki: `schema`, `settings`, `paths`, `projects` (поле `wiki_root`), `commands` (`wiki_sync`, `wiki_fetch` и хуки очереди), `convert` (HTML → Markdown), `stage`, `sync`, `search` (FTS5), `attachments`, `tick`, `api`, `mcp`. Входная точка — `MODULE` в `modules/wiki/__init__.py`.
|
||||
- Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины модулей (jira, wiki, eva) живут в модуле, новые имена в ядре — без названий модулей; проверяют `test_core_imports`, `test_core_has_no_jira_terms`, `test_core_has_no_module_terms` в `tests/test_registry.py`. Меток-исключений нет.
|
||||
- Цепочка ядра: `db` ← `settings`, `projects` ← `queue` ← `api` ← `cli`; `files` ← `queue`, `api`; реле `relay` — поток в `serve`; `backup` вызывается из `api` и из `db`. Модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core`.
|
||||
- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`).
|
||||
@ -24,16 +24,16 @@
|
||||
## Команды
|
||||
- `uv run pytest` — весь набор тестов. Зелёным считается только весь набор.
|
||||
- `uv sync` — установка окружения.
|
||||
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
|
||||
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба.
|
||||
- `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
|
||||
- `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба.
|
||||
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/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` (только при смене фикстур).
|
||||
- `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 jira-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`).
|
||||
- `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль).
|
||||
- `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`).
|
||||
|
||||
## Тесты
|
||||
- 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`) делает незакрытое соединение ошибкой набора.
|
||||
- Фронт: 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
|
||||
|
||||
# сборка веб-приложения в src/jira_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос)
|
||||
# сборка веб-приложения в src/sdlc_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос)
|
||||
web:
|
||||
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, служебные заметки, очередь команд,
|
||||
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 есть взаимная блокировка, от которой хаб зависал под параллельными запросами.
|
||||
Первый `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.
|
||||
|
||||
## Первый запуск
|
||||
|
||||
```bash
|
||||
uv run jira-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 init --config ~/projects/awp-ca/admin/jira-sync/config.example.json
|
||||
uv run sdlc-hub serve # 0.0.0.0:8790
|
||||
```
|
||||
|
||||
`init` переносит в настройки хаба всё, что относится к проекту Jira (поля, типы, карту переходов, типы связей, доску,
|
||||
приоритеты), и создаёт токены: клиентский и по одному на агента каждого модуля (`jira`, `wiki`). Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`,
|
||||
`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>"`. Клиентский токен остаётся на этой машине:
|
||||
его использует MCP (читает из базы сам).
|
||||
|
||||
@ -41,7 +41,7 @@ uv run jira-hub serve # 0.0.0.0:8790
|
||||
## Импорт пакета admin/jira-sync
|
||||
|
||||
```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:
|
||||
@ -51,24 +51,24 @@ uv run jira-hub import ~/projects/awp-ca/admin/jira-sync
|
||||
|
||||
## MCP в Claude
|
||||
|
||||
`jira-hub mcp [модуль]` запускает MCP-сервер модуля по stdio (по умолчанию `jira`); он ходит в API хаба клиентским токеном. Если хаб не запущен, инструменты
|
||||
отвечают «хаб не запущен: jira-hub serve». Регистрация вручную (файл `~/.claude.json` скрипты хаба не правят),
|
||||
`sdlc-hub mcp <модуль>` запускает MCP-сервер модуля по stdio (модуль обязателен: `jira` или `wiki`; без него или с неизвестным — код 2); серверы называются `sdlc-jira` и `sdlc-wiki`; он ходит в API хаба клиентским токеном. Если хаб не запущен, инструменты
|
||||
отвечают «хаб не запущен: sdlc-hub serve». Регистрация вручную (файл `~/.claude.json` скрипты хаба не правят),
|
||||
в user scope — MCP нужен и в `~/projects/jira-bridge`, и в `~/projects/awp-ca`, где ведут задачи AURORA:
|
||||
|
||||
```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 вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp wiki`). Регистрация в `.mcp.json` репозитория проекта (рядом с `jira-hub`, с привязкой к проекту):
|
||||
MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki`). Регистрация в `.mcp.json` репозитория проекта (рядом с `sdlc-jira`, с привязкой к проекту):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"jira-hub-wiki": {
|
||||
"sdlc-wiki": {
|
||||
"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
|
||||
{"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`,
|
||||
@ -117,7 +117,7 @@ MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp
|
||||
В примере у `ensure_issue` нет `labels`: в привязанной сессии метку проекта дописывает хаб. Новый проект — `project_create`:
|
||||
в ответе «проект <метка> «<название>» заведён, пакет обновления #N», затем указание положить в корень репозитория проекта
|
||||
`.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`,
|
||||
загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла,
|
||||
@ -134,16 +134,16 @@ MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp
|
||||
Копию вики Eva хаб получает от `eva-agent` — отдельного агента на той же виртуалке (отдельный репозиторий `aep/eva-agent`,
|
||||
каталог `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`,
|
||||
`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`).
|
||||
4. Корень вики проекта: `project_create` с `wiki_root` или MCP `root_set DOC-…` / форма на странице «Вики» (`PATCH /projects/{метка}`) —
|
||||
хаб ставит `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`.
|
||||
|
||||
## Транспорт через общую папку
|
||||
@ -152,7 +152,7 @@ MCP вики — отдельный сервер `jira-hub-wiki` (`jira-hub mcp
|
||||
в хаб через общую папку UTM. Реле работает внутри `serve` и включается настройкой:
|
||||
|
||||
```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' \
|
||||
--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
|
||||
uv run jira-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 ../jira-agent/src/agent.py # модуль jira (по умолчанию)
|
||||
uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
|
||||
```
|
||||
|
||||
Версия берётся из строки `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_status` (MCP) покажет «обновление: ok v<старая> → v<новая>». Если новая версия упала или не вышла на связь,
|
||||
запускатель вернёт прежнюю — `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 |
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
## Веб-приложение
|
||||
|
||||
Адреса: `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:
|
||||
|
||||
```
|
||||
cd ~/projects/jira-bridge/jira-hub
|
||||
cd ~/projects/jira-bridge/sdlc-hub
|
||||
git pull
|
||||
launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
|
||||
launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub
|
||||
make web
|
||||
```
|
||||
|
||||
Порядок важен для версии со схемой 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/` —
|
||||
вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента.
|
||||
|
||||
@ -322,8 +322,8 @@ make web
|
||||
|
||||
## Пример сценария
|
||||
|
||||
1. `jira-hub serve` на Mac mini, агент запущен на виртуалке.
|
||||
2. В Claude: `refresh` с `full` загружает задачи в зеркало; `jira-hub import` переносит заметки из CSV.
|
||||
1. `sdlc-hub serve` на Mac mini, агент запущен на виртуалке.
|
||||
2. В Claude: `refresh` с `full` загружает задачи в зеркало; `sdlc-hub import` переносит заметки из CSV.
|
||||
3. `batch_create` ставит пакет; `batch_wait` ждёт результата; `issue` показывает итог.
|
||||
|
||||
## Эксплуатация
|
||||
@ -341,7 +341,76 @@ make web
|
||||
перезапущен), хаб выдаёт её снова; `agent_status` показывает такие команды с числом выдач. После 3 выдач без
|
||||
отчёта команда становится `failed` («агент не отчитался после 3 выдач»), очередь идёт дальше; повтор —
|
||||
`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 version="1.0" encoding="UTF-8"?>
|
||||
@ -349,20 +418,25 @@ make web
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>ru.aepif.jira-hub</string>
|
||||
<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>jira-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>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>
|
||||
```
|
||||
|
||||
Загрузить: `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]
|
||||
name = "jira-hub"
|
||||
name = "sdlc-hub"
|
||||
version = "0.1.0"
|
||||
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
|
||||
requires-python = ">=3.13"
|
||||
@ -10,7 +10,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jira-hub = "jira_hub.cli:main"
|
||||
sdlc-hub = "sdlc_hub.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
@ -20,7 +20,7 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/jira_hub"]
|
||||
packages = ["src/sdlc_hub"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
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",
|
||||
".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"}
|
||||
ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3)
|
||||
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
|
||||
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
|
||||
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>SDLC Hub</title><p>Веб-приложение не собрано: выполните "
|
||||
"<code>make web</code> в каталоге sdlc-hub и перезапустите хаб.</p>").encode("utf-8")
|
||||
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
|
||||
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:
|
||||
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:
|
||||
srv.serve_forever()
|
||||
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)."""
|
||||
import argparse
|
||||
import json
|
||||
@ -9,7 +9,7 @@ from .paths import secure_dir
|
||||
|
||||
|
||||
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)
|
||||
p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены")
|
||||
p.add_argument("--config", required=True)
|
||||
@ -17,8 +17,8 @@ def main(argv=None) -> int:
|
||||
p = sub.add_parser("serve", help="запустить HTTP API")
|
||||
p.add_argument("--host", default="0.0.0.0")
|
||||
p.add_argument("--port", type=int)
|
||||
p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio (по умолчанию jira)")
|
||||
p.add_argument("module", nargs="?", default="jira", help="имя модуля; неизвестное — код 2 (проверка в mcp.run)")
|
||||
p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio ")
|
||||
p.add_argument("module", nargs="?", default=None, help="имя модуля (jira, wiki); без модуля или неизвестный — код 2")
|
||||
p.add_argument("--project", help="метка проекта, к которому привязана сессия")
|
||||
for m in registry.modules():
|
||||
if m.cli_register:
|
||||
@ -31,6 +31,12 @@ def main(argv=None) -> int:
|
||||
p.add_argument("path")
|
||||
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":
|
||||
from . import release
|
||||
names = [m.name for m in registry.agent_modules()]
|
||||
@ -14,7 +14,7 @@ class HubError(Exception):
|
||||
|
||||
|
||||
class HubUnavailable(Exception):
|
||||
"""Хаб не отвечает — обычно не запущен (jira-hub serve)."""
|
||||
"""Хаб не отвечает — обычно не запущен (sdlc-hub serve)."""
|
||||
|
||||
|
||||
class HubClient:
|
||||
@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from . import paths, registry
|
||||
from .client import HubClient, HubError, HubUnavailable
|
||||
|
||||
OFFLINE = "хаб не запущен: jira-hub serve"
|
||||
OFFLINE = "хаб не запущен: sdlc-hub serve"
|
||||
AUTHOR = "claude"
|
||||
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
|
||||
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"
|
||||
|
||||
|
||||
def mcp_json(label: str, module: str = registry.DEFAULT_AGENT) -> dict:
|
||||
"""Фрагмент .mcp.json для репозитория проекта: у модуля по умолчанию прежний (сервер jira-hub), у прочих модулей —
|
||||
сервер jira-hub-<модуль>."""
|
||||
run = ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp"]
|
||||
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 mcp_json(label: str, module: str) -> dict:
|
||||
"""Фрагмент .mcp.json для репозитория проекта: сервер sdlc-<модуль>, запуск `sdlc-hub mcp <модуль>`."""
|
||||
run = ["run", "--project", str(HUB_ROOT), "sdlc-hub", "mcp", module, "--project", label]
|
||||
return {"mcpServers": {f"sdlc-{module}": {"command": "uv", "args": run}}}
|
||||
|
||||
|
||||
def client() -> HubClient:
|
||||
@ -199,9 +196,13 @@ def register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Call
|
||||
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, сервер не стартует."""
|
||||
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)
|
||||
if m is None or m.mcp_server is None:
|
||||
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
|
||||
|
||||
from ...core import db, paths, settings
|
||||
@ -19,7 +19,7 @@ from .commands import describe, is_temp
|
||||
|
||||
POLL_SECONDS = 5
|
||||
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
|
||||
mcp = FastMCP("jira-hub")
|
||||
mcp = FastMCP("sdlc-jira")
|
||||
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 страниц и
|
||||
имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename."""
|
||||
import asyncio
|
||||
@ -16,7 +16,7 @@ ATTACHMENT_WAIT = 60
|
||||
SYNC_WAIT = 300
|
||||
CHUNK = 20000
|
||||
CANDIDATES = 10
|
||||
mcp = FastMCP("jira-hub-wiki")
|
||||
mcp = FastMCP("sdlc-wiki")
|
||||
|
||||
|
||||
def _project() -> dict:
|
||||
@ -242,7 +242,7 @@ def root_set(root: str) -> str:
|
||||
@core.guard
|
||||
def project_create(label: str, name: str, wiki_root: str = "") -> str:
|
||||
"""Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит
|
||||
синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki."""
|
||||
синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером sdlc-wiki."""
|
||||
try:
|
||||
code = wiki_roots.parse_root(wiki_root)
|
||||
except ValueError as e:
|
||||
@ -5,12 +5,18 @@ import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api, db, projects, registry, settings
|
||||
from jira_hub.core.client import HubClient
|
||||
from jira_hub.core.modules import CommandHooks, Module
|
||||
from jira_hub.modules.jira import MODULE as JIRA_MODULE
|
||||
from jira_hub.modules.jira import project_fields
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
from sdlc_hub.core import api, db, projects, registry, settings
|
||||
from sdlc_hub.core.client import HubClient
|
||||
from sdlc_hub.core.modules import CommandHooks, Module
|
||||
from sdlc_hub.modules.jira import MODULE as JIRA_MODULE
|
||||
from sdlc_hub.modules.jira import project_fields
|
||||
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
|
||||
|
||||
@ -134,7 +134,7 @@
|
||||
"name": "projects"
|
||||
},
|
||||
{
|
||||
"description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.",
|
||||
"description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером sdlc-wiki.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"label": {
|
||||
|
||||
@ -3,8 +3,8 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api, cli, db, queue, release, settings
|
||||
from jira_hub.core.client import HubError
|
||||
from sdlc_hub.core import api, cli, db, queue, release, settings
|
||||
from sdlc_hub.core.client import HubError
|
||||
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):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path))
|
||||
assert cli.main(["tokens"]) == 0
|
||||
lines = capsys.readouterr().out.splitlines()
|
||||
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):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
|
||||
src = tmp_path / "agent.py"
|
||||
src.write_text("AGENT_VERSION = 1\n", encoding="utf-8")
|
||||
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):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
|
||||
src = tmp_path / "agent.py"
|
||||
src.write_text("AGENT_VERSION = 1\n", encoding="utf-8")
|
||||
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
|
||||
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")
|
||||
old = tmp_path / "release" # прежняя раскладка v14: release/agent.py
|
||||
|
||||
@ -11,10 +11,10 @@ from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api, db, files, queue, release, settings
|
||||
from jira_hub.core.client import HubClient, HubError
|
||||
from jira_hub.modules.jira import api as jira_api
|
||||
from jira_hub.modules.jira.commands import CommandError
|
||||
from sdlc_hub.core import api, db, files, queue, release, settings
|
||||
from sdlc_hub.core.client import HubClient, HubError
|
||||
from sdlc_hub.modules.jira import api as jira_api
|
||||
from sdlc_hub.modules.jira.commands import CommandError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -158,7 +158,7 @@ def test_backup_created_on_first_write(hub, tmp_path):
|
||||
|
||||
|
||||
def test_client_unavailable():
|
||||
from jira_hub.core.client import HubUnavailable
|
||||
from sdlc_hub.core.client import HubUnavailable
|
||||
with pytest.raises(HubUnavailable):
|
||||
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):
|
||||
conn = hub["hub"].conn()
|
||||
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": [
|
||||
{"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
|
||||
finally:
|
||||
|
||||
@ -2,8 +2,8 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, files
|
||||
from jira_hub.modules.jira import attachments, mirror
|
||||
from sdlc_hub.core import db, files
|
||||
from sdlc_hub.modules.jira import attachments, mirror
|
||||
|
||||
|
||||
def test_fmt_size():
|
||||
|
||||
@ -5,8 +5,8 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from jira_hub.core import api, db, settings
|
||||
from jira_hub.modules.jira import mcp as jira_mcp
|
||||
from sdlc_hub.core import api, db, settings
|
||||
from sdlc_hub.modules.jira import mcp as jira_mcp
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "baseline"
|
||||
|
||||
@ -27,7 +27,7 @@ def test_mcp_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())
|
||||
_check("mcp_wiki_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema}
|
||||
for t in tools])
|
||||
|
||||
@ -2,13 +2,15 @@ import json
|
||||
import os
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
out = capsys.readouterr().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):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
|
||||
src = tmp_path / "agent.py"
|
||||
src.write_text("AGENT_VERSION = 6\n", encoding="utf-8")
|
||||
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):
|
||||
"""Диск полон при записи манифеста: сообщение и код 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.write_text("AGENT_VERSION = 8\n", encoding="utf-8")
|
||||
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"]
|
||||
|
||||
|
||||
def test_mcp_default_module(monkeypatch):
|
||||
from jira_hub.core import mcp as core_mcp
|
||||
from jira_hub.modules.jira import mcp as jira_mcp
|
||||
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(core_mcp, "PROJECT", None)
|
||||
monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append((transport, core_mcp.PROJECT)))
|
||||
assert cli.main(["mcp", "--project", "AWPCA"]) == 0
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", None) # run() пишет глобал; monkeypatch вернёт прежнее значение
|
||||
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 == [("stdio", "AWPCA"), ("stdio", "AWPCA")]
|
||||
# без --project сессия не привязана к проекту
|
||||
assert seen == ["AWPCA"]
|
||||
monkeypatch.setattr(core_mcp, "PROJECT", "STALE")
|
||||
assert cli.main(["mcp"]) == 0
|
||||
assert seen[-1] == ("stdio", None)
|
||||
assert cli.main(["mcp", "jira"]) == 0
|
||||
assert seen[-1] is None
|
||||
|
||||
|
||||
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 = []
|
||||
monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(transport))
|
||||
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):
|
||||
"""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.mkdir()
|
||||
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 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
from jira_hub.modules.jira import commands, mirror
|
||||
from jira_hub.modules.jira.commands import CommandError
|
||||
from sdlc_hub.modules.jira import commands, mirror
|
||||
from sdlc_hub.modules.jira.commands import CommandError
|
||||
|
||||
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
|
||||
|
||||
from jira_hub.core import api, backup, db, paths, projects, queue, settings
|
||||
from jira_hub.modules.jira import history
|
||||
from sdlc_hub.core import api, backup, db, paths, projects, queue, settings
|
||||
from sdlc_hub.modules.jira import history
|
||||
|
||||
|
||||
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):
|
||||
from jira_hub.core import api
|
||||
from sdlc_hub.core import api
|
||||
backups = tmp_path / "backups"
|
||||
backups.mkdir()
|
||||
backups.chmod(0o755)
|
||||
|
||||
@ -2,8 +2,8 @@ import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api, settings
|
||||
from jira_hub.core.client import HubClient, HubError
|
||||
from sdlc_hub.core import api, settings
|
||||
from sdlc_hub.core.client import HubClient, HubError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -7,7 +7,7 @@ import time
|
||||
|
||||
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…
|
||||
DAY = 86400
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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": "Петров Пётр",
|
||||
"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):
|
||||
from jira_hub.core import settings
|
||||
from sdlc_hub.core import settings
|
||||
settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"})
|
||||
history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}])
|
||||
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): # «Комментарий в ленте»
|
||||
from jira_hub.modules.jira import mirror
|
||||
from sdlc_hub.modules.jira import mirror
|
||||
mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [
|
||||
{"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)
|
||||
@ -163,7 +163,7 @@ def test_parse_kinds_unknown():
|
||||
|
||||
|
||||
def test_issue_changes(conn): # «Задача с историей»
|
||||
from jira_hub.modules.jira import mirror
|
||||
from sdlc_hub.modules.jira import mirror
|
||||
_issue(conn, "X", ["AWPCA"])
|
||||
_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")
|
||||
|
||||
@ -2,8 +2,8 @@ import csv
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import settings
|
||||
from jira_hub.modules.jira import importer, mirror
|
||||
from sdlc_hub.core import settings
|
||||
from sdlc_hub.modules.jira import importer, mirror
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -3,21 +3,34 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import jira_hub
|
||||
import sdlc_hub
|
||||
|
||||
|
||||
def test_fresh_imports():
|
||||
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)
|
||||
assert r.returncode == 0, r.stderr
|
||||
|
||||
|
||||
def test_web_app_path():
|
||||
from jira_hub.core import api
|
||||
assert api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app"
|
||||
from sdlc_hub.core import api
|
||||
assert api.WEB_APP == Path(sdlc_hub.__file__).parent / "web" / "app"
|
||||
|
||||
|
||||
def test_hub_root():
|
||||
from jira_hub.core import mcp
|
||||
from sdlc_hub.core import mcp
|
||||
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
|
||||
|
||||
from jira_hub.core import api, files, settings
|
||||
from jira_hub.core import mcp as core_mcp
|
||||
from jira_hub.modules.jira import mcp as jmcp
|
||||
from jira_hub.modules.jira import commands
|
||||
from jira_hub.core.client import HubClient
|
||||
from sdlc_hub.core import api, files, settings
|
||||
from sdlc_hub.core import mcp as core_mcp
|
||||
from sdlc_hub.modules.jira import mcp as jmcp
|
||||
from sdlc_hub.modules.jira import commands
|
||||
from sdlc_hub.core.client import HubClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -503,16 +503,20 @@ def test_project_create(live, monkeypatch): # сце
|
||||
out = jmcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
|
||||
assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out
|
||||
frag = json.loads(out[out.index("{"):])
|
||||
assert frag == core_mcp.mcp_json("STOCKS_DRRK")
|
||||
assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"]
|
||||
assert frag == core_mcp.mcp_json("STOCKS_DRRK", "jira")
|
||||
assert frag["mcpServers"]["sdlc-jira"]["args"][-2:] == ["--project", "STOCKS_DRRK"]
|
||||
assert live["client"].get("/batches")["batches"][0]["author"] == "claude"
|
||||
assert jmcp.project_create("awpca", "x").startswith("ошибка хаба 409")
|
||||
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():
|
||||
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():
|
||||
@ -700,7 +704,7 @@ class FakeHub:
|
||||
|
||||
@pytest.fixture
|
||||
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(jmcp, "POLL_SECONDS", 0.05)
|
||||
return tmp_path / "home" / "downloads"
|
||||
@ -819,7 +823,7 @@ def test_attachment_failed(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)
|
||||
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
|
||||
_use(monkeypatch, hub)
|
||||
@ -828,7 +832,7 @@ def test_attachment_413(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)]),
|
||||
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
|
||||
_use(monkeypatch, hub)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db
|
||||
from jira_hub.modules.jira import mirror
|
||||
from sdlc_hub.core import db
|
||||
from sdlc_hub.modules.jira import mirror
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"""Поля проекта от модулей; доски — в модуле jira (wiki-module, решение 8, спеки modules/data-model/hub-api)."""
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, mcp as core_mcp, registry
|
||||
from jira_hub.core.client import HubError
|
||||
from jira_hub.core.modules import Module, ProjectField
|
||||
from jira_hub.modules.jira import mirror
|
||||
from sdlc_hub.core import db, mcp as core_mcp, registry
|
||||
from sdlc_hub.core.client import HubError
|
||||
from sdlc_hub.core.modules import Module, ProjectField
|
||||
from sdlc_hub.modules.jira import mirror
|
||||
from v6db import make_v6_db
|
||||
|
||||
|
||||
@ -80,7 +80,7 @@ def test_editable_field_needs_updater():
|
||||
|
||||
|
||||
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")
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
@ -150,11 +150,12 @@ def test_field_clashes_with_core(name):
|
||||
|
||||
|
||||
def test_mcp_json_per_module():
|
||||
first = core_mcp.mcp_json("AWPCA")["mcpServers"]
|
||||
assert list(first) == ["jira-hub"] and first["jira-hub"]["args"][-3:] == ["mcp", "--project", "AWPCA"]
|
||||
first = core_mcp.mcp_json("AWPCA", "jira")["mcpServers"]
|
||||
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"]
|
||||
assert list(other) == ["jira-hub-wiki"]
|
||||
assert other["jira-hub-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"]
|
||||
assert list(other) == ["sdlc-wiki"]
|
||||
assert other["sdlc-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"]
|
||||
|
||||
|
||||
def test_fmt_projects_columns():
|
||||
@ -168,9 +169,9 @@ def two_modules(monkeypatch, open_db):
|
||||
"""Реестр [jira, stub]: stub объявляет изменяемое поле root, хранит его в setting и ставит пакеты-пустышки."""
|
||||
import json
|
||||
import re
|
||||
from jira_hub.core import api, queue
|
||||
from jira_hub.core.http import ApiError
|
||||
from jira_hub.modules.jira import MODULE as JIRA
|
||||
from sdlc_hub.core import api, queue
|
||||
from sdlc_hub.core.http import ApiError
|
||||
from sdlc_hub.modules.jira import MODULE as JIRA
|
||||
|
||||
def check_root(v):
|
||||
if not isinstance(v, str) or not v:
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api, projects, settings
|
||||
from jira_hub.modules.jira import mirror
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
from sdlc_hub.core import api, projects, settings
|
||||
from sdlc_hub.modules.jira import mirror
|
||||
from sdlc_hub.modules.jira import projects as jira_projects
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -2,12 +2,12 @@ import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api, db, files, queue, settings
|
||||
from jira_hub.modules.jira import attachments, commands, mirror, refresh
|
||||
from jira_hub.modules.jira import fetch as jira_fetch
|
||||
from jira_hub.modules.jira import projects as jira_projects
|
||||
from jira_hub.modules.jira.commands import CommandError
|
||||
from jira_hub.core.queue import ClaimedError
|
||||
from sdlc_hub.core import api, db, files, queue, settings
|
||||
from sdlc_hub.modules.jira import attachments, commands, mirror, refresh
|
||||
from sdlc_hub.modules.jira import fetch as jira_fetch
|
||||
from sdlc_hub.modules.jira import projects as jira_projects
|
||||
from sdlc_hub.modules.jira.commands import CommandError
|
||||
from sdlc_hub.core.queue import ClaimedError
|
||||
|
||||
LT = {"связанные": "ASUPR_Связанные"}
|
||||
|
||||
@ -210,7 +210,7 @@ def test_no_backup_for_refresh_batch(conn):
|
||||
|
||||
|
||||
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")
|
||||
for i in range(4):
|
||||
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):
|
||||
"""Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью)."""
|
||||
from jira_hub.core import backup
|
||||
from sdlc_hub.core import backup
|
||||
conn = open_db(tmp_path / "hub.sqlite")
|
||||
backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0)
|
||||
for i in range(4):
|
||||
|
||||
@ -5,10 +5,10 @@ from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import files, queue, registry
|
||||
from jira_hub.core.errors import CommandError
|
||||
from jira_hub.core.modules import CommandHooks, Module
|
||||
from jira_hub.modules import jira
|
||||
from sdlc_hub.core import files, queue, registry
|
||||
from sdlc_hub.core.errors import CommandError
|
||||
from sdlc_hub.core.modules import CommandHooks, Module
|
||||
from sdlc_hub.modules import jira
|
||||
|
||||
SHA = "a" * 64
|
||||
|
||||
|
||||
@ -5,10 +5,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import registry
|
||||
from jira_hub.core.modules import CommandHooks, Module
|
||||
from sdlc_hub.core import registry
|
||||
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 пуст
|
||||
PENDING: set[str] = set()
|
||||
@ -50,7 +50,7 @@ def test_route_conflict():
|
||||
|
||||
|
||||
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
|
||||
mods = tuple(r for m in registry.modules() for r in m.routes)
|
||||
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):
|
||||
"""Импорты пакета jira_hub.modules (абсолютные и относительные из core/)."""
|
||||
"""Импорты пакета sdlc_hub.modules (абсолютные и относительные из core/)."""
|
||||
found = []
|
||||
for n in (tree.body if top_level_only else ast.walk(tree)):
|
||||
if isinstance(n, ast.Import):
|
||||
names = [a.name for a in n.names]
|
||||
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(".")
|
||||
names = [base] + [base + "." + a.name for a in n.names]
|
||||
else:
|
||||
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)
|
||||
return found
|
||||
|
||||
|
||||
@ -8,8 +8,8 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api, relay, settings
|
||||
from jira_hub.core.client import HubClient
|
||||
from sdlc_hub.core import api, relay, settings
|
||||
from sdlc_hub.core.client import HubClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
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"):
|
||||
|
||||
@ -3,9 +3,9 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import queue, settings
|
||||
from jira_hub.modules.jira import settings as jira_settings
|
||||
from jira_hub.modules.jira.commands import CommandError
|
||||
from sdlc_hub.core import queue, settings
|
||||
from sdlc_hub.modules.jira import settings as jira_settings
|
||||
from sdlc_hub.modules.jira.commands import CommandError
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import api
|
||||
from sdlc_hub.core import api
|
||||
|
||||
|
||||
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):
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
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"])
|
||||
|
||||
@ -8,8 +8,8 @@ from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import files
|
||||
from jira_hub.core.client import HubError
|
||||
from sdlc_hub.core import files
|
||||
from sdlc_hub.core.client import HubError
|
||||
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 — фикстура
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@ from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, queue, settings
|
||||
from jira_hub.core.errors import CommandError
|
||||
from jira_hub.modules.jira import refresh
|
||||
from jira_hub.modules.wiki import commands, settings as wiki_settings, tick
|
||||
from sdlc_hub.core import db, queue, settings
|
||||
from sdlc_hub.core.errors import CommandError
|
||||
from sdlc_hub.modules.jira import refresh
|
||||
from sdlc_hub.modules.wiki import commands, settings as wiki_settings, tick
|
||||
|
||||
|
||||
@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