#!/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 = "https://git.aepif.ru/aep/sdlc-hub.git" projects: List[Path] = field(default_factory=list) port: int = 8790 @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 "https://git.aepif.ru/aep/sdlc-hub.git", 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), ) @property def old_checkout(self) -> Path: return self.bridge / "jira-hub" @property def new_checkout(self) -> Path: return self.bridge / "sdlc-hub" def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: env = dict(os.environ, GIT_OPTIONAL_LOCKS="0") return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, env=env) def preflight(cfg: Config) -> List[str]: """Проверки шага 1. Только чтение.""" problems: List[str] = [] old = cfg.old_checkout 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(): 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() # ls-remote вместо fetch: проверка остаётся строго чтением (fetch пишет FETCH_HEAD) lr = _git(old, "ls-remote", "origin", "refs/heads/main") remote_head = lr.stdout.split()[0] if lr.returncode == 0 and lr.stdout.split() else "" if not remote_head: 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) 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 "нет ответа")) 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 rewrite_mcp_json(path: Path, checkout: Path) -> bool: 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", [])) tail: List[str] = [] for marker in ("mcp", "jira-hub"): if marker in args: tail = args[args.index(marker) + 1:] break 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("%s" % a for a in [uv, "run", "--project", str(checkout), "sdlc-hub", "serve"]) return ( '\n' '\n' '\n' " Label%s\n" " ProgramArguments%s\n" " EnvironmentVariables\n" " PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\n" " PYTHONUNBUFFERED1\n" " \n" " RunAtLoad\n" " KeepAlive\n" " StandardOutPath%s\n" " StandardErrorPath%s\n" "\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 _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") new_plist = cfg.launch_agents / (NEW_LABEL + ".plist") log = cfg.logs / "sdlc-hub.log" uv = shutil.which("uv") or "/opt/homebrew/bin/uv" def stop() -> None: _run("launchctl", "bootout", "%s/%s" % (_domain(), OLD_LABEL), check=False) # не загружен - не ошибка if old_plist.exists(): old_plist.unlink() def move_data() -> None: shutil.move(str(cfg.data_root / "jira-hub"), str(cfg.data_root / "sdlc-hub")) def move_checkout() -> None: shutil.move(str(cfg.old_checkout), str(new_co)) 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) 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 = time.time() + 30 while time.time() < deadline: try: socket.create_connection(("127.0.0.1", cfg.port), timeout=1).close() return except OSError: time.sleep(1) raise RuntimeError("хаб не ответил на порту %d за 30 с (лог %s)" % (cfg.port, log)) def consumers() -> None: for project in cfg.projects: 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) 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", "remove", "-s", "user", "jira-hub") _run("claude", "mcp", "add", "-s", "user", "sdlc-jira", "--", "uv", "run", "--project", str(new_co), "sdlc-hub", "mcp", "jira") return [ ("2. остановить launchd %s и удалить %s" % (OLD_LABEL, old_plist), 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), ("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), ("7. user scope Claude: remove jira-hub, add sdlc-jira", 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(" ок") 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 for desc, fn in plan: print("-> " + desc) try: fn() except Exception as e: # noqa: BLE001 print("остановлено на шаге %s: %s" % (desc.split(".")[0], e)) return 1 print(" готово") print("\nОсталось владельцу:") print(" - /mcp reconnect в открытых сессиях Claude") print(" - codegraph init в %s и в каждом worktree (.wt/*)" % cfg.new_checkout) print(" - проверить правки и удалить копии *%s:" % SUFFIX) for b in backups: print(" " + str(b)) if leftovers: print(" - проверить руками (остались упоминания jira-hub):") for ln in leftovers: print(" " + ln) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))