fix(sdlc-rename): cutover: потребители автообнаружением, origin по протоколу, предупреждения о сессиях, plist в .pre-sdlc
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0461916222
commit
102bee83fe
23
README.md
23
README.md
@ -375,19 +375,24 @@ make web
|
|||||||
2. Закрыть или не трогать сессии Claude с MCP хаба (после переключения -- `/mcp` reconnect).
|
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`
|
3. `git -C ~/projects/jira-bridge/jira-hub pull --ff-only`, сразу `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh`
|
||||||
-- сухой прогон, проверить вывод (шаг 1 скрипта: чистый checkout на `main` = `origin/main`, доступен `aep/sdlc-hub`,
|
-- сухой прогон, проверить вывод (шаг 1 скрипта: чистый checkout на `main` = `origin/main`, доступен `aep/sdlc-hub`,
|
||||||
нет целевых каталогов, есть `uv`, `make`, `claude`, `launchctl`, `node`). Между pull и переключением работающий хаб
|
нет целевых каталогов, есть `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 -- окно в несколько минут.
|
живёт на загруженном коде, а новые сессии Claude не поднимут MCP -- окно в несколько минут.
|
||||||
4. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh --apply`. Шаги скрипта, останавливается на первой ошибке:
|
4. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh --apply`. Шаги скрипта, останавливается на первой ошибке:
|
||||||
2. остановить `ru.aepif.jira-hub` и удалить его plist, дождаться закрытия порта 8790;
|
2. остановить `ru.aepif.jira-hub`, дождаться закрытия порта 8790, переименовать его plist в `ru.aepif.jira-hub.plist.pre-sdlc`;
|
||||||
3. `~/.local/share/jira-hub` -> `~/.local/share/sdlc-hub`;
|
3. `~/.local/share/jira-hub` -> `~/.local/share/sdlc-hub`;
|
||||||
4. checkout `jira-bridge/jira-hub` -> `jira-bridge/sdlc-hub`, `git worktree repair` (включая `.wt/*`), `origin` -> `aep/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`;
|
новый `.venv`, `uv sync`, `make web`;
|
||||||
5. поставить `ru.aepif.sdlc-hub` (plist выше), запустить, дождаться порта;
|
5. поставить `ru.aepif.sdlc-hub` (plist выше), запустить, дождаться порта;
|
||||||
6. править `.mcp.json`, `.claude/settings*.json`, `CLAUDE.md`, `.claude/skills` в `~/projects/awp-ca` и
|
6. править `.mcp.json`, `.claude/settings*.json`, `CLAUDE.md`, `.claude/skills` в `~/projects/awp-ca`,
|
||||||
`~/projects/stocks_drrk` (копии рядом: `*.pre-sdlc`);
|
`~/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`.
|
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`
|
5. Просмотреть правки в awp-ca и stocks_drrk, проверить оставшиеся упоминания `jira-hub` из вывода скрипта, удалить копии `*.pre-sdlc`
|
||||||
(это не git-репозитории -- коммитить нечего). `/mcp` reconnect; `codegraph init` в новом checkout и в каждом worktree `.wt/*`.
|
(это не 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/` без повторного входа
|
6. Проверить: `agent_status` через `sdlc-jira`, `status` через `sdlc-wiki`, веб `http://127.0.0.1:8790/app/` без повторного входа
|
||||||
(токен и раскладка доски переносятся из прежних ключей браузерного хранилища).
|
(токен и раскладка доски переносятся из прежних ключей браузерного хранилища).
|
||||||
|
|
||||||
@ -399,11 +404,11 @@ make web
|
|||||||
Если скрипт остановился или хаб не поднялся (скрипт печатает «Откат: …» со своими путями):
|
Если скрипт остановился или хаб не поднялся (скрипт печатает «Откат: …» со своими путями):
|
||||||
|
|
||||||
1. Остановить новый хаб: `launchctl bootout gui/$(id -u)/ru.aepif.sdlc-hub`; удалить `~/Library/LaunchAgents/ru.aepif.sdlc-hub.plist`.
|
1. Остановить новый хаб: `launchctl bootout gui/$(id -u)/ru.aepif.sdlc-hub`; удалить `~/Library/LaunchAgents/ru.aepif.sdlc-hub.plist`.
|
||||||
2. Вернуть каталоги: `mv ~/.local/share/sdlc-hub ~/.local/share/jira-hub`; `mv ~/projects/jira-bridge/sdlc-hub ~/projects/jira-bridge/jira-hub`,
|
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/*`).
|
затем `git -C ~/projects/jira-bridge/jira-hub worktree repair` (и пути всех `.wt/*`).
|
||||||
3. Вернуть `origin` на `aep/jira-hub` (`git remote set-url origin …`; если репозиторий в Gitea уже переименован -- адрес прежнего имени
|
3. Вернуть `origin` на `aep/jira-hub` (`git remote set-url origin …`; если репозиторий в Gitea уже переименован -- адрес прежнего имени
|
||||||
перенаправляется, но лучше убедиться) и `git checkout` коммита `main` перед слиянием PR `sdlc-rename` (найти в `git log`); `uv sync` и при необходимости `make web`.
|
перенаправляется, но лучше убедиться) и `git checkout` коммита `main` перед слиянием PR `sdlc-rename` (найти в `git log`); `uv sync` и при необходимости `make web`.
|
||||||
4. Вернуть прежний plist `~/Library/LaunchAgents/ru.aepif.jira-hub.plist` и загрузить его (`launchctl bootstrap gui/$(id -u) …`):
|
4. Вернуть прежний plist (скрипт оставил копию `ru.aepif.jira-hub.plist.pre-sdlc`: `mv` без суффикса; иначе создать заново по тексту ниже) и загрузить его (`launchctl bootstrap gui/$(id -u) …`):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@ -426,7 +431,7 @@ make web
|
|||||||
</dict></plist>
|
</dict></plist>
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Вернуть файлы проектов из копий: в `~/projects/awp-ca` и `~/projects/stocks_drrk` каждый `*.pre-sdlc` переместить поверх оригинала
|
5. Вернуть файлы проектов из копий: в каждом проекте-потребителе (awp-ca, stocks_drrk, sfm_foxpro и т. д.) каждый `*.pre-sdlc` переместить поверх оригинала
|
||||||
(`mv файл.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`, затем
|
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.
|
`claude mcp remove -s user sdlc-jira`; `/mcp` reconnect.
|
||||||
|
|||||||
@ -110,5 +110,6 @@
|
|||||||
| 15 | 24.10.2026 | R-4: ключи `commentsPref` → `sdlc-hub-comments-hidden` (localStorage, с миграцией через `readMigrated`) и `IssueFilters` → `sdlc-hub-issue-filters` (sessionStorage, без миграции) | остатки `jira-hub-` в вебе нарушали проверку «только legacyStorage»; настройка комментариев переживает сессии, фильтры живут в рамках вкладки | однократный сброс настройки/фильтров, откат двух ключей |
|
| 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, а не имя пакета | правка комментария |
|
| 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 сравнивает с реальной вершиной сервера | без сети проверка даёт ошибку (безопасная сторона) |
|
| 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 с | не загруженный сервис — не ошибка; порядок следует из зависимостей путей | правка порядка шагов внутри скрипта до живого запуска |
|
| 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) | уточнение текста отката |
|
| 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` |
|
||||||
|
|||||||
@ -210,15 +210,18 @@ published_at}` или `null`), `update_state`, `launcher_version`, `agent_report
|
|||||||
`scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL
|
`scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL
|
||||||
только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке:
|
только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке:
|
||||||
1. проверки: основной checkout `jira-bridge/jira-hub` чистый, на `main` и совпадает с `origin/main` (владелец сделал
|
1. проверки: основной checkout `jira-bridge/jira-hub` чистый, на `main` и совпадает с `origin/main` (владелец сделал
|
||||||
`git pull --ff-only` прямо перед запуском: скрипт приходит этим pull); в Gitea доступен `aep/sdlc-hub` (владелец
|
`git pull --ff-only` прямо перед запуском: скрипт приходит этим pull; неотслеживаемая прежняя сборка `src/jira_hub/`
|
||||||
переименовал репозиторий); `~/.local/share/sdlc-hub/` и `jira-bridge/sdlc-hub` не существуют;
|
без tracked-файлов не считается грязью); в Gitea доступен `aep/sdlc-hub` (владелец переименовал репозиторий; адрес
|
||||||
2. остановить launchd `ru.aepif.jira-hub` и удалить его plist;
|
выводится из `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`;
|
3. перенести каталог данных `~/.local/share/jira-hub` → `~/.local/share/sdlc-hub`;
|
||||||
4. перенести checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub`, `git worktree repair`, `origin` →
|
4. перенести checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub`, удалить неотслеживаемый `src/jira_hub`,
|
||||||
`aep/sdlc-hub`, пересоздать `.venv` (`uv sync`), `make web`;
|
`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`, лог
|
5. поставить и запустить launchd `ru.aepif.sdlc-hub` (`uv run --project <checkout> sdlc-hub serve`, лог
|
||||||
`~/Library/Logs/sdlc-hub.log`) и дождаться ответа хаба на порту из настроек;
|
`~/Library/Logs/sdlc-hub.log`) и дождаться ответа хаба на порту из настроек;
|
||||||
6. в проектах из списка (по умолчанию `~/projects/awp-ca`, `~/projects/stocks_drrk`): в `.mcp.json` заменить записи
|
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`) с новым путём; в
|
`jira-hub` → `sdlc-jira` (`sdlc-hub mcp jira`) и `jira-hub-wiki` → `sdlc-wiki` (`sdlc-hub mcp wiki`) с новым путём; в
|
||||||
`.claude/settings*.json` — имена серверов (`enabledMcpjsonServers` и т. п.) и разрешения `mcp__jira-hub__` →
|
`.claude/settings*.json` — имена серверов (`enabledMcpjsonServers` и т. п.) и разрешения `mcp__jira-hub__` →
|
||||||
`mcp__sdlc-jira__`, `mcp__jira-hub-wiki__` → `mcp__sdlc-wiki__`; в `CLAUDE.md` и `.claude/skills/**` — те же имена
|
`mcp__sdlc-jira__`, `mcp__jira-hub-wiki__` → `mcp__sdlc-wiki__`; в `CLAUDE.md` и `.claude/skills/**` — те же имена
|
||||||
|
|||||||
@ -33,9 +33,10 @@ class Config:
|
|||||||
data_root: Path
|
data_root: Path
|
||||||
launch_agents: Path
|
launch_agents: Path
|
||||||
logs: Path
|
logs: Path
|
||||||
remote: str = "https://git.aepif.ru/aep/sdlc-hub.git"
|
remote: str = "" # пусто -> выводится из origin старого checkout заменой /aep/jira-hub на /aep/sdlc-hub
|
||||||
projects: List[Path] = field(default_factory=list)
|
projects: List[Path] = field(default_factory=list)
|
||||||
port: int = 8790
|
port: int = 8790
|
||||||
|
projects_root: Path = None # где искать потребителей автоматически: <projects_root>/*/.mcp.json
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Config":
|
def from_env(cls) -> "Config":
|
||||||
@ -47,10 +48,11 @@ class Config:
|
|||||||
data_root=Path(e.get("CUTOVER_DATA_ROOT") or h / ".local" / "share"),
|
data_root=Path(e.get("CUTOVER_DATA_ROOT") or h / ".local" / "share"),
|
||||||
launch_agents=Path(e.get("CUTOVER_LAUNCH_AGENTS") or h / "Library" / "LaunchAgents"),
|
launch_agents=Path(e.get("CUTOVER_LAUNCH_AGENTS") or h / "Library" / "LaunchAgents"),
|
||||||
logs=Path(e.get("CUTOVER_LOGS") or h / "Library" / "Logs"),
|
logs=Path(e.get("CUTOVER_LOGS") or h / "Library" / "Logs"),
|
||||||
remote=e.get("CUTOVER_REMOTE") or "https://git.aepif.ru/aep/sdlc-hub.git",
|
remote=e.get("CUTOVER_REMOTE") or "",
|
||||||
projects=[Path(p) for p in projects.split(":") if p] if projects is not None
|
projects=[Path(p) for p in projects.split(":") if p] if projects is not None
|
||||||
else [h / "projects" / "awp-ca", h / "projects" / "stocks_drrk"],
|
else [h / "projects" / "awp-ca", h / "projects" / "stocks_drrk"],
|
||||||
port=int(e.get("CUTOVER_PORT") or 8790),
|
port=int(e.get("CUTOVER_PORT") or 8790),
|
||||||
|
projects_root=Path(e.get("CUTOVER_PROJECTS_ROOT") or h / "projects"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -71,20 +73,53 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
|||||||
stdin=subprocess.DEVNULL)
|
stdin=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
OLD_REPO = "/aep/jira-hub"
|
||||||
|
NEW_REPO = "/aep/sdlc-hub"
|
||||||
|
|
||||||
|
|
||||||
|
def derive_remote(origin: str) -> str:
|
||||||
|
"""Адрес нового репозитория из адреса 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]:
|
def preflight(cfg: Config) -> List[str]:
|
||||||
"""Проверки шага 1. Только чтение."""
|
"""Проверки шага 1. Только чтение."""
|
||||||
problems: List[str] = []
|
problems: List[str] = []
|
||||||
for tool in ("uv", "make", "claude", "launchctl", "node"):
|
for tool in ("uv", "make", "claude", "launchctl", "node", "npm"):
|
||||||
if not shutil.which(tool):
|
if not shutil.which(tool):
|
||||||
problems.append("не найден инструмент %s в PATH (нужен для шагов переключения)" % tool)
|
problems.append("не найден инструмент %s в PATH (нужен для шагов переключения)" % tool)
|
||||||
old = cfg.old_checkout
|
old = cfg.old_checkout
|
||||||
|
remote = cfg.remote
|
||||||
if not old.is_dir():
|
if not old.is_dir():
|
||||||
problems.append("старый checkout %s не найден" % old)
|
problems.append("старый checkout %s не найден" % old)
|
||||||
else:
|
else:
|
||||||
st = _git(old, "status", "--porcelain")
|
st = _git(old, "status", "--porcelain")
|
||||||
if st.returncode != 0:
|
if st.returncode != 0:
|
||||||
problems.append("git status в %s не выполнился: %s" % (old, st.stderr.strip()))
|
problems.append("git status в %s не выполнился: %s" % (old, st.stderr.strip()))
|
||||||
elif st.stdout.strip():
|
elif _checkout_dirty(old, st.stdout):
|
||||||
problems.append("checkout %s не чист (есть незакоммиченные изменения)" % old)
|
problems.append("checkout %s не чист (есть незакоммиченные изменения)" % old)
|
||||||
br = _git(old, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
br = _git(old, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
||||||
if br != "main":
|
if br != "main":
|
||||||
@ -97,11 +132,16 @@ def preflight(cfg: Config) -> List[str]:
|
|||||||
problems.append("не удалось получить origin/main из %s" % old)
|
problems.append("не удалось получить origin/main из %s" % old)
|
||||||
elif remote_head != head:
|
elif remote_head != head:
|
||||||
problems.append("HEAD не совпадает с origin/main: сделайте git pull --ff-only в %s" % old)
|
problems.append("HEAD не совпадает с origin/main: сделайте git pull --ff-only в %s" % old)
|
||||||
lr = subprocess.run(["git", "ls-remote", cfg.remote], capture_output=True, text=True, env=_git_env(),
|
if not remote:
|
||||||
stdin=subprocess.DEVNULL)
|
remote, prob = target_remote(cfg)
|
||||||
if lr.returncode != 0:
|
if prob:
|
||||||
problems.append("репозиторий %s недоступен: переименуйте aep/jira-hub -> aep/sdlc-hub в Gitea (%s)"
|
problems.append(prob)
|
||||||
% (cfg.remote, lr.stderr.strip().splitlines()[-1] if lr.stderr.strip() else "нет ответа"))
|
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):
|
for p in (cfg.data_root / "sdlc-hub", cfg.new_checkout):
|
||||||
if p.exists():
|
if p.exists():
|
||||||
problems.append("каталог %s уже существует" % p)
|
problems.append("каталог %s уже существует" % p)
|
||||||
@ -110,7 +150,43 @@ def preflight(cfg: Config) -> List[str]:
|
|||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
def rewrite_mcp_json(path: Path, checkout: Path) -> bool:
|
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"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
servers = data.get("mcpServers")
|
servers = data.get("mcpServers")
|
||||||
if not isinstance(servers, dict):
|
if not isinstance(servers, dict):
|
||||||
@ -124,11 +200,13 @@ def rewrite_mcp_json(path: Path, checkout: Path) -> bool:
|
|||||||
continue
|
continue
|
||||||
new_key, module = names[key]
|
new_key, module = names[key]
|
||||||
args = list(val.get("args", []))
|
args = list(val.get("args", []))
|
||||||
tail: List[str] = []
|
marker = next((m for m in ("mcp", "jira-hub") if m in args), None)
|
||||||
for marker in ("mcp", "jira-hub"):
|
if os.path.basename(str(val.get("command", ""))) != "uv" or marker is None:
|
||||||
if marker in args:
|
new[key] = val
|
||||||
tail = args[args.index(marker) + 1:]
|
if unrewritten is not None:
|
||||||
break
|
unrewritten.append("%s: запись %s нестандартной формы, не переписана" % (path, key))
|
||||||
|
continue
|
||||||
|
tail = args[args.index(marker) + 1:]
|
||||||
if tail and tail[0] in ("jira", "wiki"):
|
if tail and tail[0] in ("jira", "wiki"):
|
||||||
tail = tail[1:]
|
tail = tail[1:]
|
||||||
val = dict(val)
|
val = dict(val)
|
||||||
@ -238,25 +316,35 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None)
|
|||||||
backups = backups if backups is not None else []
|
backups = backups if backups is not None else []
|
||||||
new_co = cfg.new_checkout
|
new_co = cfg.new_checkout
|
||||||
old_plist = cfg.launch_agents / (OLD_LABEL + ".plist")
|
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")
|
new_plist = cfg.launch_agents / (NEW_LABEL + ".plist")
|
||||||
log = cfg.logs / "sdlc-hub.log"
|
log = cfg.logs / "sdlc-hub.log"
|
||||||
uv = shutil.which("uv") or "/opt/homebrew/bin/uv"
|
uv = shutil.which("uv") or "/opt/homebrew/bin/uv"
|
||||||
|
remote_shown = cfg.remote or "производный от origin"
|
||||||
|
|
||||||
def stop() -> None:
|
def stop() -> None:
|
||||||
_run("launchctl", "bootout", "%s/%s" % (_domain(), OLD_LABEL), check=False) # не загружен - не ошибка
|
_run("launchctl", "bootout", "%s/%s" % (_domain(), OLD_LABEL), check=False) # не загружен - не ошибка
|
||||||
if old_plist.exists():
|
|
||||||
old_plist.unlink()
|
|
||||||
if not _wait_port_closed(cfg.port):
|
if not _wait_port_closed(cfg.port):
|
||||||
raise RuntimeError("после остановки %s порт %d всё ещё занят: хаб не остановлен" % (OLD_LABEL, 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:
|
def move_data() -> None:
|
||||||
shutil.move(str(cfg.data_root / "jira-hub"), str(cfg.data_root / "sdlc-hub"))
|
shutil.move(str(cfg.data_root / "jira-hub"), str(cfg.data_root / "sdlc-hub"))
|
||||||
|
|
||||||
def move_checkout() -> None:
|
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))
|
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 []
|
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), "worktree", "repair", *[str(p) for p in wts])
|
||||||
_run("git", "-C", str(new_co), "remote", "set-url", "origin", cfg.remote)
|
_run("git", "-C", str(new_co), "remote", "set-url", "origin", remote)
|
||||||
shutil.rmtree(str(new_co / ".venv"), ignore_errors=True)
|
shutil.rmtree(str(new_co / ".venv"), ignore_errors=True)
|
||||||
_run("uv", "sync", cwd=new_co)
|
_run("uv", "sync", cwd=new_co)
|
||||||
_run("make", "web", cwd=new_co)
|
_run("make", "web", cwd=new_co)
|
||||||
@ -266,24 +354,22 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None)
|
|||||||
cfg.logs.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")
|
new_plist.write_text(plist_text(new_co, log, uv), encoding="utf-8")
|
||||||
_run("launchctl", "bootstrap", _domain(), str(new_plist))
|
_run("launchctl", "bootstrap", _domain(), str(new_plist))
|
||||||
deadline = time.time() + 30
|
deadline = _clock() + 30
|
||||||
while time.time() < deadline:
|
while _clock() < deadline:
|
||||||
if _port_open(cfg.port):
|
if _port_open(cfg.port):
|
||||||
return
|
return
|
||||||
time.sleep(1)
|
_sleep(1)
|
||||||
raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s); остановить: launchctl bootout %s/%s"
|
raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s); остановить: launchctl bootout %s/%s"
|
||||||
% (cfg.port, log, _domain(), NEW_LABEL))
|
% (cfg.port, log, _domain(), NEW_LABEL))
|
||||||
|
|
||||||
def consumers() -> None:
|
def consumers() -> None:
|
||||||
for project in cfg.projects:
|
for project in consumer_projects(cfg):
|
||||||
mcp = project / ".mcp.json"
|
mcp = project / ".mcp.json"
|
||||||
if mcp.is_file():
|
if mcp.is_file() and "jira-hub" in mcp.read_text(encoding="utf-8"):
|
||||||
text = mcp.read_text(encoding="utf-8")
|
backups.append(backup(mcp))
|
||||||
if "jira-hub" in text:
|
rewrite_mcp_json(mcp, new_co, leftovers)
|
||||||
backups.append(backup(mcp))
|
leftovers.extend("%s: %s" % (mcp, ln.strip())
|
||||||
rewrite_mcp_json(mcp, new_co)
|
for ln in mcp.read_text(encoding="utf-8").splitlines() if "jira-hub" in ln)
|
||||||
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):
|
for f in _project_files(project):
|
||||||
try:
|
try:
|
||||||
if "jira-hub" not in f.read_text(encoding="utf-8"):
|
if "jira-hub" not in f.read_text(encoding="utf-8"):
|
||||||
@ -302,13 +388,14 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None)
|
|||||||
% r.returncode)
|
% r.returncode)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
("2. остановить launchd %s и удалить %s" % (OLD_LABEL, old_plist), stop),
|
("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),
|
("3. перенести данные %s -> %s" % (cfg.data_root / "jira-hub", cfg.data_root / "sdlc-hub"), move_data),
|
||||||
("4. перенести checkout %s -> %s, worktree repair, origin -> %s, uv sync, make web"
|
("4. перенести checkout %s -> %s, убрать неотслеживаемый src/jira_hub, worktree repair, origin -> %s, "
|
||||||
% (cfg.old_checkout, new_co, cfg.remote), move_checkout),
|
"uv sync, make web" % (cfg.old_checkout, new_co, remote_shown), move_checkout),
|
||||||
("5. поставить и запустить %s (лог %s), дождаться порта %d" % (NEW_LABEL, log, cfg.port), start),
|
("5. поставить и запустить %s (лог %s), дождаться порта %d" % (NEW_LABEL, log, cfg.port), start),
|
||||||
("6. править проекты %s (.mcp.json, .claude/settings*.json, CLAUDE.md, .claude/skills; копии *%s)"
|
("6. править проекты %s (.mcp.json, .claude/settings*.json, CLAUDE.md, .claude/skills; копии *%s)"
|
||||||
% (", ".join(str(p) for p in cfg.projects), SUFFIX), consumers),
|
% (", ".join(str(p) for p in consumer_projects(cfg)), SUFFIX), consumers),
|
||||||
("7. user scope Claude: add sdlc-jira, затем remove jira-hub", user_scope),
|
("7. user scope Claude: add sdlc-jira, затем remove jira-hub", user_scope),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -325,6 +412,8 @@ def main(argv: List[str]) -> int:
|
|||||||
print(" ПРОБЛЕМА: " + p)
|
print(" ПРОБЛЕМА: " + p)
|
||||||
else:
|
else:
|
||||||
print(" ок")
|
print(" ок")
|
||||||
|
for w in warnings(cfg):
|
||||||
|
print(" ПРЕДУПРЕЖДЕНИЕ: " + w if not w.startswith(" ") else w)
|
||||||
leftovers: List[str] = []
|
leftovers: List[str] = []
|
||||||
backups: List[Path] = []
|
backups: List[Path] = []
|
||||||
plan = steps(cfg, leftovers, backups)
|
plan = steps(cfg, leftovers, backups)
|
||||||
@ -347,20 +436,24 @@ def main(argv: List[str]) -> int:
|
|||||||
print("Промежуточное состояние (по выполненным шагам; упавший шаг мог выполниться частично, проверьте):")
|
print("Промежуточное состояние (по выполненным шагам; упавший шаг мог выполниться частично, проверьте):")
|
||||||
for line in _state_lines(done, desc):
|
for line in _state_lines(done, desc):
|
||||||
print(" " + line)
|
print(" " + line)
|
||||||
|
old_plist = cfg.launch_agents / (OLD_LABEL + ".plist")
|
||||||
print("Откат: остановить ru.aepif.sdlc-hub (launchctl bootout %s/%s), перенести каталоги обратно "
|
print("Откат: остановить ru.aepif.sdlc-hub (launchctl bootout %s/%s), перенести каталоги обратно "
|
||||||
"(данные %s -> %s, checkout %s -> %s, origin вернуть на aep/jira-hub), вернуть plist %s и "
|
"(данные %s -> %s, checkout %s -> %s, origin вернуть на aep/jira-hub), вернуть plist "
|
||||||
"launchctl bootstrap %s %s; раздел «Переключение с jira-hub» в README." % (
|
"(mv %s %s) и launchctl bootstrap %s %s; раздел «Переключение с jira-hub» в README." % (
|
||||||
_domain(), NEW_LABEL, cfg.data_root / "sdlc-hub", cfg.data_root / "jira-hub",
|
_domain(), NEW_LABEL, cfg.data_root / "sdlc-hub", cfg.data_root / "jira-hub",
|
||||||
cfg.new_checkout, cfg.old_checkout, cfg.launch_agents / (OLD_LABEL + ".plist"), _domain(),
|
cfg.new_checkout, cfg.old_checkout, str(old_plist) + SUFFIX, old_plist, _domain(), old_plist))
|
||||||
cfg.launch_agents / (OLD_LABEL + ".plist")))
|
_print_tail(cfg, done, backups, leftovers)
|
||||||
_print_tail(backups, leftovers)
|
|
||||||
return 1
|
return 1
|
||||||
done.append(desc)
|
done.append(desc)
|
||||||
print(" готово")
|
print(" готово")
|
||||||
print("\nОсталось владельцу:")
|
print("\nОсталось владельцу:")
|
||||||
print(" - /mcp reconnect в открытых сессиях Claude")
|
print(" - /mcp reconnect в открытых сессиях Claude")
|
||||||
print(" - codegraph init в %s и в каждом worktree (.wt/*)" % cfg.new_checkout)
|
print(" - codegraph init в %s и в каждом worktree (.wt/*)" % cfg.new_checkout)
|
||||||
_print_tail(backups, leftovers)
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@ -368,7 +461,7 @@ def _state_lines(done: List[str], failed: str = "") -> List[str]:
|
|||||||
n = {d.split(".")[0] for d in done}
|
n = {d.split(".")[0] for d in done}
|
||||||
out = []
|
out = []
|
||||||
if "2" in n:
|
if "2" in n:
|
||||||
out.append("хаб ru.aepif.jira-hub остановлен, его plist удалён")
|
out.append("хаб ru.aepif.jira-hub остановлен, его plist переименован в *%s" % SUFFIX)
|
||||||
if "3" in n:
|
if "3" in n:
|
||||||
out.append("каталог данных перенесён в sdlc-hub")
|
out.append("каталог данных перенесён в sdlc-hub")
|
||||||
if "4" in n:
|
if "4" in n:
|
||||||
@ -379,18 +472,22 @@ def _state_lines(done: List[str], failed: str = "") -> List[str]:
|
|||||||
out.append("файлы проектов изменены (копии *%s)" % SUFFIX)
|
out.append("файлы проектов изменены (копии *%s)" % SUFFIX)
|
||||||
if failed:
|
if failed:
|
||||||
if failed.startswith("2."):
|
if failed.startswith("2."):
|
||||||
out.append("упавший шаг 2: хаб ru.aepif.jira-hub мог быть выгружен, его plist удалён")
|
out.append("упавший шаг 2: хаб ru.aepif.jira-hub мог быть выгружен; его plist мог быть переименован в *%s"
|
||||||
|
% SUFFIX)
|
||||||
else:
|
else:
|
||||||
out.append("упавший шаг %s мог выполниться частично, проверьте" % failed.split(".")[0])
|
out.append("упавший шаг %s мог выполниться частично, проверьте" % failed.split(".")[0])
|
||||||
return out or ["ничего не изменено"]
|
return out or ["ничего не изменено"]
|
||||||
|
|
||||||
|
|
||||||
def _print_tail(backups: List[Path], leftovers: List[str]) -> None:
|
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)
|
print(" - проверить правки и удалить копии *%s:" % SUFFIX)
|
||||||
for b in backups:
|
for b in backups:
|
||||||
print(" " + str(b))
|
print(" " + str(b))
|
||||||
if leftovers:
|
if leftovers:
|
||||||
print(" - проверить руками (остались упоминания jira-hub):")
|
print(" - проверить руками (остались упоминания jira-hub или запись не переписана):")
|
||||||
for ln in leftovers:
|
for ln in leftovers:
|
||||||
print(" " + ln)
|
print(" " + ln)
|
||||||
|
|
||||||
|
|||||||
@ -80,6 +80,9 @@ def test_mcp_module_required(monkeypatch, capsys):
|
|||||||
assert seen == []
|
assert seen == []
|
||||||
assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0
|
assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0
|
||||||
assert seen == ["AWPCA"]
|
assert seen == ["AWPCA"]
|
||||||
|
monkeypatch.setattr(core_mcp, "PROJECT", "STALE")
|
||||||
|
assert cli.main(["mcp", "jira"]) == 0
|
||||||
|
assert seen[-1] is None
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_unknown_module(monkeypatch, capsys):
|
def test_mcp_unknown_module(monkeypatch, capsys):
|
||||||
|
|||||||
@ -61,6 +61,8 @@ def env(tmp_path, monkeypatch):
|
|||||||
}.items():
|
}.items():
|
||||||
monkeypatch.setenv(k, str(v))
|
monkeypatch.setenv(k, str(v))
|
||||||
monkeypatch.setattr(cutover.shutil, "which", lambda name: "/usr/bin/" + name)
|
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}
|
return {"tmp": tmp_path, "bridge": bridge, "old": old, "data": data, "bare": bare, "proj": proj}
|
||||||
|
|
||||||
|
|
||||||
@ -195,7 +197,7 @@ def test_step2_fails_if_port_still_open(env, monkeypatch, capsys):
|
|||||||
assert cutover.main(["--apply"]) == 1
|
assert cutover.main(["--apply"]) == 1
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "порт" in out and "остановлено на шаге 2" in out
|
assert "порт" in out and "остановлено на шаге 2" in out
|
||||||
assert "мог быть выгружен" in out and "plist удалён" in out
|
assert "мог быть выгружен" in out and "переименован" in out
|
||||||
assert "ничего не изменено" not 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 "launchctl bootstrap gui/%d %s" % (os.getuid(), env["tmp"] / "la" / "ru.aepif.jira-hub.plist") in out
|
||||||
assert t[0] >= 10
|
assert t[0] >= 10
|
||||||
@ -225,3 +227,143 @@ def test_failure_prints_state_and_rollback(env, monkeypatch, capsys):
|
|||||||
assert "2. a" in out and "3. b" in out
|
assert "2. a" in out and "3. b" in out
|
||||||
assert "хаб ru.aepif.jira-hub остановлен" in out and "каталог данных перенесён" in out
|
assert "хаб ru.aepif.jira-hub остановлен" in out and "каталог данных перенесён" in out
|
||||||
assert "Откат" in out and "README" 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"),
|
||||||
|
])
|
||||||
|
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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user