diff --git a/README.md b/README.md index 1f564c3..96e4040 100644 --- a/README.md +++ b/README.md @@ -375,19 +375,24 @@ make web 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` = `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 -- окно в несколько минут. 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`; - 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`; 5. поставить `ru.aepif.sdlc-hub` (plist выше), запустить, дождаться порта; - 6. править `.mcp.json`, `.claude/settings*.json`, `CLAUDE.md`, `.claude/skills` в `~/projects/awp-ca` и - `~/projects/stocks_drrk` (копии рядом: `*.pre-sdlc`); + 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 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` в новом 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/` без повторного входа (токен и раскладка доски переносятся из прежних ключей браузерного хранилища). @@ -399,11 +404,11 @@ make web Если скрипт остановился или хаб не поднялся (скрипт печатает «Откат: …» со своими путями): 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/*`). 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 `~/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 @@ -426,7 +431,7 @@ make web ``` -5. Вернуть файлы проектов из копий: в `~/projects/awp-ca` и `~/projects/stocks_drrk` каждый `*.pre-sdlc` переместить поверх оригинала +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. diff --git a/openspec/changes/sdlc-rename/design.md b/openspec/changes/sdlc-rename/design.md index 6d60ee8..ef53b6e 100644 --- a/openspec/changes/sdlc-rename/design.md +++ b/openspec/changes/sdlc-rename/design.md @@ -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»; настройка комментариев переживает сессии, фильтры живут в рамках вкладки | однократный сброс настройки/фильтров, откат двух ключей | | 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 с | не загруженный сервис — не ошибка; порядок следует из зависимостей путей | правка порядка шагов внутри скрипта до живого запуска | +| 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` | diff --git a/openspec/changes/sdlc-rename/specs/hub-api/spec.md b/openspec/changes/sdlc-rename/specs/hub-api/spec.md index 3106f28..3a84839 100644 --- a/openspec/changes/sdlc-rename/specs/hub-api/spec.md +++ b/openspec/changes/sdlc-rename/specs/hub-api/spec.md @@ -210,15 +210,18 @@ published_at}` или `null`), `update_state`, `launcher_version`, `agent_report `scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке: 1. проверки: основной checkout `jira-bridge/jira-hub` чистый, на `main` и совпадает с `origin/main` (владелец сделал - `git pull --ff-only` прямо перед запуском: скрипт приходит этим pull); в Gitea доступен `aep/sdlc-hub` (владелец - переименовал репозиторий); `~/.local/share/sdlc-hub/` и `jira-bridge/sdlc-hub` не существуют; -2. остановить launchd `ru.aepif.jira-hub` и удалить его plist; + `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`, `git worktree repair`, `origin` → - `aep/sdlc-hub`, пересоздать `.venv` (`uv sync`), `make web`; +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 sdlc-hub serve`, лог `~/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`) с новым путём; в `.claude/settings*.json` — имена серверов (`enabledMcpjsonServers` и т. п.) и разрешения `mcp__jira-hub__` → `mcp__sdlc-jira__`, `mcp__jira-hub-wiki__` → `mcp__sdlc-wiki__`; в `CLAUDE.md` и `.claude/skills/**` — те же имена diff --git a/scripts/cutover.py b/scripts/cutover.py index 0d1600b..79c7d9f 100644 --- a/scripts/cutover.py +++ b/scripts/cutover.py @@ -33,9 +33,10 @@ class Config: data_root: Path launch_agents: 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) port: int = 8790 + projects_root: Path = None # где искать потребителей автоматически: /*/.mcp.json @classmethod def from_env(cls) -> "Config": @@ -47,10 +48,11 @@ class Config: 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 "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 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 @@ -71,20 +73,53 @@ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: 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]: """Проверки шага 1. Только чтение.""" 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): 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 st.stdout.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": @@ -97,11 +132,16 @@ def preflight(cfg: Config) -> List[str]: problems.append("не удалось получить origin/main из %s" % old) elif remote_head != head: 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(), - stdin=subprocess.DEVNULL) - if lr.returncode != 0: - problems.append("репозиторий %s недоступен: переименуйте aep/jira-hub -> aep/sdlc-hub в Gitea (%s)" - % (cfg.remote, lr.stderr.strip().splitlines()[-1] if lr.stderr.strip() else "нет ответа")) + if not remote: + remote, prob = target_remote(cfg) + if prob: + problems.append(prob) + 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) @@ -110,7 +150,43 @@ def preflight(cfg: Config) -> List[str]: 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]: + """Список проектов-потребителей: заданные плюс найденные /*/.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): @@ -124,11 +200,13 @@ def rewrite_mcp_json(path: Path, checkout: Path) -> bool: continue new_key, module = names[key] args = list(val.get("args", [])) - tail: List[str] = [] - for marker in ("mcp", "jira-hub"): - if marker in args: - tail = args[args.index(marker) + 1:] - break + 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) @@ -238,25 +316,35 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) 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 old_plist.exists(): - old_plist.unlink() 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", cfg.remote) + _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) @@ -266,24 +354,22 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) 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 = time.time() + 30 - while time.time() < deadline: + deadline = _clock() + 30 + while _clock() < deadline: if _port_open(cfg.port): return - time.sleep(1) + _sleep(1) raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s); остановить: launchctl bootout %s/%s" % (cfg.port, log, _domain(), NEW_LABEL)) def consumers() -> None: - for project in cfg.projects: + for project in consumer_projects(cfg): mcp = project / ".mcp.json" - if mcp.is_file(): - text = mcp.read_text(encoding="utf-8") - if "jira-hub" in text: - backups.append(backup(mcp)) - rewrite_mcp_json(mcp, new_co) - leftovers.extend("%s: %s" % (mcp, ln.strip()) - for ln in mcp.read_text(encoding="utf-8").splitlines() if "jira-hub" in ln) + 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"): @@ -302,13 +388,14 @@ def steps(cfg: Config, leftovers: List[str] = None, backups: List[Path] = None) % r.returncode) 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), - ("4. перенести checkout %s -> %s, worktree repair, origin -> %s, uv sync, make web" - % (cfg.old_checkout, new_co, cfg.remote), move_checkout), + ("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 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), ] @@ -325,6 +412,8 @@ def main(argv: List[str]) -> int: 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) @@ -347,20 +436,24 @@ def main(argv: List[str]) -> int: 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 %s и " - "launchctl bootstrap %s %s; раздел «Переключение с jira-hub» в README." % ( + "(данные %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, cfg.launch_agents / (OLD_LABEL + ".plist"), _domain(), - cfg.launch_agents / (OLD_LABEL + ".plist"))) - _print_tail(backups, leftovers) + 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_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 @@ -368,7 +461,7 @@ 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 удалён") + out.append("хаб ru.aepif.jira-hub остановлен, его plist переименован в *%s" % SUFFIX) if "3" in n: out.append("каталог данных перенесён в sdlc-hub") if "4" in n: @@ -379,18 +472,22 @@ def _state_lines(done: List[str], failed: str = "") -> List[str]: out.append("файлы проектов изменены (копии *%s)" % SUFFIX) if failed: if failed.startswith("2."): - out.append("упавший шаг 2: хаб ru.aepif.jira-hub мог быть выгружен, его plist удалён") + out.append("упавший шаг 2: хаб ru.aepif.jira-hub мог быть выгружен; его plist мог быть переименован в *%s" + % SUFFIX) else: out.append("упавший шаг %s мог выполниться частично, проверьте" % failed.split(".")[0]) 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) for b in backups: print(" " + str(b)) if leftovers: - print(" - проверить руками (остались упоминания jira-hub):") + print(" - проверить руками (остались упоминания jira-hub или запись не переписана):") for ln in leftovers: print(" " + ln) diff --git a/tests/test_cli.py b/tests/test_cli.py index 168f641..0799e63 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,6 +80,9 @@ def test_mcp_module_required(monkeypatch, capsys): assert seen == [] assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0 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): diff --git a/tests/test_cutover.py b/tests/test_cutover.py index ace66ae..00c3092 100644 --- a/tests/test_cutover.py +++ b/tests/test_cutover.py @@ -61,6 +61,8 @@ def env(tmp_path, monkeypatch): }.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} @@ -195,7 +197,7 @@ def test_step2_fails_if_port_still_open(env, monkeypatch, capsys): assert cutover.main(["--apply"]) == 1 out = capsys.readouterr().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 "launchctl bootstrap gui/%d %s" % (os.getuid(), env["tmp"] / "la" / "ru.aepif.jira-hub.plist") in out 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 "хаб 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"), +]) +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()