"""Скрипт переключения живой установки: только сухой прогон, отказы preflight и чистые функции.""" import importlib.util import json import os import plistlib import sys import subprocess from pathlib import Path import pytest SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "cutover.py" _spec = importlib.util.spec_from_file_location("cutover", SCRIPT) cutover = importlib.util.module_from_spec(_spec) sys.modules["cutover"] = cutover _spec.loader.exec_module(cutover) GIT_ENV = { "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", } def git(*args, cwd): subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True, env={**os.environ, **GIT_ENV}) def snapshot(root: Path): out = {} for p in sorted(root.rglob("*")): st = p.stat() out[str(p)] = (st.st_mtime_ns, st.st_size) return out @pytest.fixture def env(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() monkeypatch.setenv("HOME", str(home)) bridge = tmp_path / "bridge" bare = tmp_path / "remote.git" bare.mkdir() git("init", "--bare", "-b", "main", cwd=bare) old = bridge / "jira-hub" old.mkdir(parents=True) git("init", "-b", "main", cwd=old) (old / "a.txt").write_text("x") git("add", ".", cwd=old) git("commit", "-m", "init", cwd=old) git("remote", "add", "origin", str(bare), cwd=old) git("push", "-u", "origin", "main", cwd=old) data = tmp_path / "share" (data / "jira-hub").mkdir(parents=True) (data / "jira-hub" / "hub.sqlite").write_text("db") proj = tmp_path / "proj" proj.mkdir() (proj / ".mcp.json").write_text(json.dumps({"mcpServers": {"jira-hub": {"command": "uv", "args": []}}})) for k, v in { "CUTOVER_BRIDGE": bridge, "CUTOVER_DATA_ROOT": data, "CUTOVER_LAUNCH_AGENTS": tmp_path / "la", "CUTOVER_LOGS": tmp_path / "logs", "CUTOVER_REMOTE": bare, "CUTOVER_PROJECTS": proj, }.items(): monkeypatch.setenv(k, str(v)) monkeypatch.setattr(cutover.shutil, "which", lambda name: "/usr/bin/" + name) monkeypatch.setattr(cutover, "_pgrep_mcp", lambda: []) monkeypatch.setenv("CUTOVER_PROJECTS_ROOT", str(tmp_path / "projroot")) return {"tmp": tmp_path, "bridge": bridge, "old": old, "data": data, "bare": bare, "proj": proj} def test_rewrite_mcp_json(tmp_path): p = tmp_path / ".mcp.json" old = "/old/jira-hub" p.write_text(json.dumps({"mcpServers": { "jira-hub": {"command": "uv", "args": ["run", "--project", old, "jira-hub", "mcp", "--project", "AWPCA"]}, "jira-hub-wiki": {"command": "uv", "args": ["run", "--project", old, "jira-hub", "mcp", "wiki", "--project", "AWPCA"]}, "other": {"command": "x", "args": ["1"]}, }})) co = Path("/new/sdlc-hub") assert cutover.rewrite_mcp_json(p, co) is True servers = json.loads(p.read_text())["mcpServers"] assert list(servers) == ["sdlc-jira", "sdlc-wiki", "other"] assert servers["sdlc-jira"]["args"] == ["run", "--project", str(co), "sdlc-hub", "mcp", "jira", "--project", "AWPCA"] assert servers["sdlc-wiki"]["args"] == ["run", "--project", str(co), "sdlc-hub", "mcp", "wiki", "--project", "AWPCA"] assert servers["other"] == {"command": "x", "args": ["1"]} assert cutover.rewrite_mcp_json(p, co) is False def test_rewrite_text_settings(tmp_path): p = tmp_path / "settings.json" p.write_text(json.dumps({"enabledMcpjsonServers": ["jira-hub", "jira-hub-wiki"], "permissions": {"allow": ["mcp__jira-hub__issue", "mcp__jira-hub-wiki__page"]}})) assert cutover.rewrite_text(p) == [] d = json.loads(p.read_text()) assert d["enabledMcpjsonServers"] == ["sdlc-jira", "sdlc-wiki"] assert d["permissions"]["allow"] == ["mcp__sdlc-jira__issue", "mcp__sdlc-wiki__page"] def test_rewrite_text_reports_leftovers(tmp_path): p = tmp_path / "CLAUDE.md" p.write_text("Мост Jira (aep/jira-hub, aep/jira-agent) работает через MCP `jira-hub` (user scope)\nкаталог jira-hub\n") left = cutover.rewrite_text(p) text = p.read_text() assert "aep/sdlc-hub" in text and "MCP `sdlc-jira`" in text assert left == ["каталог jira-hub"] def test_backup(tmp_path): p = tmp_path / "f.json" p.write_text("a") b = cutover.backup(p) assert b == tmp_path / "f.json.pre-sdlc" and b.read_text() == "a" def test_plist_text(tmp_path): log = tmp_path / "sdlc-hub.log" d = plistlib.loads(cutover.plist_text(tmp_path / "sdlc-hub", log, "/opt/homebrew/bin/uv").encode()) assert d["Label"] == "ru.aepif.sdlc-hub" assert d["ProgramArguments"][-2:] == ["sdlc-hub", "serve"] assert d["ProgramArguments"][0] == "/opt/homebrew/bin/uv" assert d["StandardOutPath"] == d["StandardErrorPath"] == str(log) assert d["KeepAlive"] is True def test_dry_run_changes_nothing(env, capsys): before = snapshot(env["tmp"]) assert cutover.main([]) == 0 assert snapshot(env["tmp"]) == before assert "2." in capsys.readouterr().out def test_apply_refuses_without_remote(env, capsys): os.environ["CUTOVER_REMOTE"] = str(env["tmp"] / "nope.git") before = snapshot(env["tmp"]) assert cutover.main(["--apply"]) == 1 assert "aep/sdlc-hub" in capsys.readouterr().out assert snapshot(env["tmp"]) == before def test_apply_refuses_when_target_exists(env, capsys): (env["data"] / "sdlc-hub").mkdir() before = snapshot(env["tmp"]) assert cutover.main(["--apply"]) == 1 assert "уже существует" in capsys.readouterr().out assert snapshot(env["tmp"]) == before def test_dirty_checkout_refused(env): (env["old"] / "dirty.txt").write_text("x") probs = cutover.preflight(cutover.Config.from_env()) assert any("не чист" in s for s in probs) def test_preflight_clean(env): assert cutover.preflight(cutover.Config.from_env()) == [] def test_preflight_reports_missing_tool(env, monkeypatch): monkeypatch.setattr(cutover.shutil, "which", lambda name: None if name in ("claude", "make") else "/x/" + name) probs = cutover.preflight(cutover.Config.from_env()) assert any("claude" in s for s in probs) and any("make" in s for s in probs) assert not any("uv " in s for s in probs) class _Fake: def __init__(self, rc_by_cmd=None): self.calls = [] self.rc_by_cmd = rc_by_cmd or {} def __call__(self, cmd, **kw): self.calls.append((list(cmd), kw)) rc = self.rc_by_cmd.get(cmd[2] if len(cmd) > 2 else "", 0) r = subprocess.CompletedProcess(cmd, rc) if kw.get("check") and rc: raise subprocess.CalledProcessError(rc, cmd) return r def test_step7_add_before_remove_and_remove_not_fatal(env, monkeypatch, capsys): fake = _Fake({"remove": 1}) monkeypatch.setattr(cutover.subprocess, "run", fake) cfg = cutover.Config.from_env() desc, fn = cutover.steps(cfg)[-1] fn() cmds = [c for c, _ in fake.calls] assert cmds[0][:3] == ["claude", "mcp", "add"] and "sdlc-jira" in cmds[0] assert cmds[1][:3] == ["claude", "mcp", "remove"] assert fake.calls[1][1].get("check") is False assert "ПРЕДУПРЕЖДЕНИЕ" in capsys.readouterr().out def test_step2_fails_if_port_still_open(env, monkeypatch, capsys): monkeypatch.setattr(cutover.subprocess, "run", _Fake()) monkeypatch.setattr(cutover, "_port_open", lambda port: True) t = [0.0] monkeypatch.setattr(cutover, "_clock", lambda: t[0]) monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d)) monkeypatch.setattr(cutover, "preflight", lambda cfg: []) assert cutover.main(["--apply"]) == 1 out = capsys.readouterr().out assert "порт" in out and "остановлено на шаге 2" in out assert "мог быть выгружен" in out and "переименован" in out assert "ничего не изменено" not in out assert "launchctl bootstrap gui/%d %s" % (os.getuid(), env["tmp"] / "la" / "ru.aepif.jira-hub.plist") in out assert t[0] >= 10 def test_step2_waits_for_port_to_close(env, monkeypatch): monkeypatch.setattr(cutover.subprocess, "run", _Fake()) answers = iter([True, True, True, False]) monkeypatch.setattr(cutover, "_port_open", lambda port: next(answers)) t = [0.0] monkeypatch.setattr(cutover, "_clock", lambda: t[0]) monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d)) cutover.steps(cutover.Config.from_env())[0][1]() assert 0 < t[0] < 10 def test_failure_prints_state_and_rollback(env, monkeypatch, capsys): def boom(): raise RuntimeError("сломалось") plan = [("2. a", lambda: None), ("3. b", lambda: None), ("4. c", boom)] monkeypatch.setattr(cutover, "preflight", lambda cfg: []) monkeypatch.setattr(cutover, "steps", lambda cfg, leftovers=None, backups=None: plan) assert cutover.main(["--apply"]) == 1 out = capsys.readouterr().out assert "остановлено на шаге 4: сломалось" in out assert "2. a" in out and "3. b" in out assert "хаб ru.aepif.jira-hub остановлен" in out and "каталог данных перенесён" in out assert "Откат" in out and "README" in out def test_preflight_ignores_untracked_old_build(env): (env["old"] / "src" / "sdlc_hub").mkdir(parents=True) # как в живом checkout: src/ уже отслеживается (env["old"] / "src" / "sdlc_hub" / "__init__.py").write_text("") git("add", ".", cwd=env["old"]) git("commit", "-m", "pkg", cwd=env["old"]) git("push", "origin", "main", cwd=env["old"]) d = env["old"] / "src" / "jira_hub" / "web" / "app" d.mkdir(parents=True) (d / "x.js").write_text("x") assert cutover.preflight(cutover.Config.from_env()) == [] def test_preflight_tracked_or_other_untracked_is_dirty(env): d = env["old"] / "src" / "jira_hub" d.mkdir(parents=True) (d / "t.py").write_text("x") git("add", ".", cwd=env["old"]) git("commit", "-m", "tracked", cwd=env["old"]) git("push", "origin", "main", cwd=env["old"]) (d / "new.py").write_text("y") assert any("не чист" in s for s in cutover.preflight(cutover.Config.from_env())) (d / "new.py").unlink() (env["old"] / "other.txt").write_text("z") assert any("не чист" in s for s in cutover.preflight(cutover.Config.from_env())) @pytest.mark.parametrize("origin,expected", [ ("ssh://git@git.aepif.ru:2222/aep/jira-hub.git", "ssh://git@git.aepif.ru:2222/aep/sdlc-hub.git"), ("https://git.aepif.ru/aep/jira-hub.git", "https://git.aepif.ru/aep/sdlc-hub.git"), ("ssh://git@git.aepif.ru:2222/aep/sdlc-hub.git", "ssh://git@git.aepif.ru:2222/aep/sdlc-hub.git"), # origin уже сменён ]) def test_derive_remote(origin, expected): assert cutover.derive_remote(origin) == expected def test_target_remote_from_origin_and_problem(env, monkeypatch): monkeypatch.delenv("CUTOVER_REMOTE") cfg = cutover.Config.from_env() assert cfg.remote == "" url, prob = cutover.target_remote(cfg) assert url == "" and "CUTOVER_REMOTE" in prob # origin фикстуры - путь без /aep/jira-hub git("remote", "set-url", "origin", "ssh://git@h:2222/aep/jira-hub.git", cwd=env["old"]) assert cutover.target_remote(cfg) == ("ssh://git@h:2222/aep/sdlc-hub.git", "") def test_consumers_autodiscovery(env): root = env["tmp"] / "projroot" sfm = root / "sfm" sfm.mkdir(parents=True) (sfm / ".mcp.json").write_text('{"mcpServers": {"jira-hub": {}}}') other = root / "other" other.mkdir() (other / ".mcp.json").write_text('{"mcpServers": {}}') cfg = cutover.Config.from_env() assert cutover.consumer_projects(cfg) == [env["proj"], sfm] def test_dry_run_lists_consumers_and_pgrep_warning(env, monkeypatch, capsys): sfm = env["tmp"] / "projroot" / "sfm" sfm.mkdir(parents=True) (sfm / ".mcp.json").write_text('{"mcpServers": {"jira-hub": {}}}') monkeypatch.setattr(cutover, "_pgrep_mcp", lambda: ["123 uv run jira-hub mcp"]) before = snapshot(env["tmp"]) assert cutover.main([]) == 0 out = capsys.readouterr().out assert str(sfm) in out and "123 uv run jira-hub mcp" in out and "ПРЕДУПРЕЖДЕНИЕ" in out assert snapshot(env["tmp"]) == before def test_rewrite_mcp_json_reports_nonstandard(tmp_path): p = tmp_path / ".mcp.json" p.write_text(json.dumps({"mcpServers": { "jira-hub": {"command": "python", "args": ["x"]}, "jira-hub-wiki": {"command": "uv", "args": ["run", "foo"]}, }})) un = [] assert cutover.rewrite_mcp_json(p, Path("/c"), un) is False assert len(un) == 2 and all(str(p) in m for m in un) assert list(json.loads(p.read_text())["mcpServers"]) == ["jira-hub", "jira-hub-wiki"] def test_print_tail_warns_about_recreated_data_dir(env, capsys): cfg = cutover.Config.from_env() (env["data"] / "jira-hub").rename(env["data"] / "sdlc-hub") cutover._print_tail(cfg, ["3. x"], [], []) assert "появился заново" not in capsys.readouterr().out (env["data"] / "jira-hub").mkdir() cutover._print_tail(cfg, ["3. x"], [], []) assert "появился заново" in capsys.readouterr().out def test_step2_renames_plist(env, monkeypatch): monkeypatch.setattr(cutover.subprocess, "run", _Fake()) monkeypatch.setattr(cutover, "_port_open", lambda port: False) la = env["tmp"] / "la" la.mkdir() (la / "ru.aepif.jira-hub.plist").write_text("p") cutover.steps(cutover.Config.from_env())[0][1]() assert not (la / "ru.aepif.jira-hub.plist").exists() assert (la / "ru.aepif.jira-hub.plist.pre-sdlc").read_text() == "p" def test_step3_moves_data(env): cutover.steps(cutover.Config.from_env())[1][1]() assert (env["data"] / "sdlc-hub" / "hub.sqlite").read_text() == "db" assert not (env["data"] / "jira-hub").exists() def test_step4_moves_checkout_and_sets_origin(env, monkeypatch): fake = _Fake() monkeypatch.setattr(cutover.subprocess, "run", fake) monkeypatch.setattr(cutover, "_git", lambda repo, *a: subprocess.CompletedProcess(a, 0, stdout="", stderr="")) (env["old"] / "src" / "jira_hub" / "web").mkdir(parents=True) (env["old"] / ".venv").mkdir() cutover.steps(cutover.Config.from_env())[2][1]() new = env["bridge"] / "sdlc-hub" assert new.is_dir() and not env["old"].exists() assert not (new / "src" / "jira_hub").exists() and not (new / ".venv").exists() cmds = [c for c, _ in fake.calls] assert ["git", "-C", str(new), "remote", "set-url", "origin", str(env["bare"])] in cmds assert ["uv", "sync"] in cmds and ["make", "web"] in cmds def test_step6_rewrites_consumers_with_backups(env): proj = env["proj"] (proj / ".mcp.json").write_text(json.dumps({"mcpServers": {"jira-hub": { "command": "uv", "args": ["run", "--project", "/o", "jira-hub", "mcp", "--project", "AWPCA"]}}})) (proj / ".claude" / "skills" / "s").mkdir(parents=True) (proj / ".claude" / "settings.local.json").write_text('{"enabledMcpjsonServers": ["jira-hub"]}') (proj / ".claude" / "skills" / "s" / "SKILL.md").write_text("mcp__jira-hub__issue и каталог jira-hub\n") (proj / "CLAUDE.md").write_text("без упоминаний\n") left, backs = [], [] cutover.steps(cutover.Config.from_env(), left, backs)[4][1]() names = sorted(b.name for b in backs) assert names == [".mcp.json.pre-sdlc", "SKILL.md.pre-sdlc", "settings.local.json.pre-sdlc"] assert "sdlc-jira" in (proj / ".mcp.json").read_text() assert "sdlc-jira" in (proj / ".claude" / "settings.local.json").read_text() assert any("каталог jira-hub" in ln for ln in left) assert not (proj / "CLAUDE.md.pre-sdlc").exists() def test_preflight_checks_head_against_new_remote(env): """Gitea не перенаправляет git по SSH после переименования: старый origin недоступен, сверка — с новым адресом.""" git("remote", "set-url", "origin", str(env["tmp"] / "gone.git"), cwd=env["old"]) problems = cutover.preflight(cutover.Config.from_env()) assert not any("origin/main" in s or "HEAD не совпадает" in s for s in problems), problems other = env["tmp"] / "other" git("clone", str(env["bare"]), str(other), cwd=env["tmp"]) (other / "b.txt").write_text("y") git("add", ".", cwd=other) git("commit", "-m", "ahead", cwd=other) git("push", "origin", "main", cwd=other) assert any("HEAD не совпадает" in s for s in cutover.preflight(cutover.Config.from_env()))