sdlc-hub/tests/test_cutover.py
Andrey Epifancev eb86563e2a fix(sdlc-rename): cutover сверяет HEAD с новым репозиторием — Gitea не перенаправляет SSH
После переименования в Gitea старый адрес по SSH отвечает «Cannot find
repository» (T6.2 подтвердилось): preflight сверяет main по адресу
aep/sdlc-hub, derive_remote принимает уже сменённый origin; README —
сменить origin сразу после переименования.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:09:56 +04:00

385 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Скрипт переключения живой установки: только сухой прогон, отказы 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()))