fix(sdlc-rename): cutover: потребители автообнаружением, origin по протоколу, предупреждения о сессиях, plist в .pre-sdlc

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 12:41:25 +04:00
parent 0461916222
commit 102bee83fe
6 changed files with 311 additions and 60 deletions

View File

@ -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 <checkout> 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
<?xml version="1.0" encoding="UTF-8"?>
@ -426,7 +431,7 @@ make web
</dict></plist>
```
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.

View File

@ -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` |

View File

@ -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 <checkout> 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/**` — те же имена

View File

@ -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 # где искать потребителей автоматически: <projects_root>/*/.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]:
"""Список проектов-потребителей: заданные плюс найденные <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"))
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)

View File

@ -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):

View File

@ -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()