feat: add repository rename orchestration CLI
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
parent
6312db8700
commit
6b82215ae7
11 changed files with 1373 additions and 4 deletions
|
|
@ -325,7 +325,7 @@
|
|||
| task | STATE-WP-0085-T02 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T03 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T04 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T05 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T05 | done | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T06 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T07 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
| task | STATE-WP-0085-T08 | todo | — | workplans/STATE-WP-0085-repository-lineage-preserving-rename.md |
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ app.include_router(recently_on_scope.router)
|
|||
app.include_router(consistency_sweep.router)
|
||||
app.include_router(repos.router)
|
||||
app.include_router(repository_renames.router)
|
||||
app.include_router(repository_renames.operation_router)
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(workstreams.workplan_router)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from api.services.repository_rename import (
|
|||
create_operation,
|
||||
list_operations,
|
||||
load_operation,
|
||||
load_operation_by_id,
|
||||
rollback_preflight,
|
||||
verify_forge_identity,
|
||||
verify_operation,
|
||||
|
|
@ -38,6 +39,9 @@ from api.services.repository_rename import (
|
|||
|
||||
|
||||
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
|
||||
operation_router = APIRouter(
|
||||
prefix="/repository-renames", tags=["repository-renames"]
|
||||
)
|
||||
|
||||
|
||||
def _raise(exc: RenameLifecycleError) -> None:
|
||||
|
|
@ -122,7 +126,22 @@ async def create_repository_rename_operation(
|
|||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
operation = await create_operation(session, gateway, repo_id, body)
|
||||
operation, no_op = await create_operation(session, gateway, repo_id, body)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation, no_op=no_op)
|
||||
|
||||
|
||||
@operation_router.get(
|
||||
"/operations/{operation_id}", response_model=RepositoryRenameOperationRead
|
||||
)
|
||||
async def get_repository_rename_operation_by_id(
|
||||
operation_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve an operation journal without requiring its repository UUID."""
|
||||
try:
|
||||
operation = await load_operation_by_id(session, operation_id)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
return _operation_read(operation)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class ForgeIdentityVerifyRequest(BaseModel):
|
|||
|
||||
|
||||
class RepositoryRenameOperationCreate(BaseModel):
|
||||
operation_id: uuid.UUID | None = None
|
||||
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
preflight_token: str
|
||||
confirmation: str
|
||||
|
|
|
|||
|
|
@ -695,7 +695,22 @@ async def create_operation(
|
|||
gateway: ForgeRepositoryGateway,
|
||||
repo_id: uuid.UUID,
|
||||
body: RepositoryRenameOperationCreate,
|
||||
) -> RepositoryRenameOperation:
|
||||
) -> tuple[RepositoryRenameOperation, bool]:
|
||||
if body.operation_id is not None:
|
||||
existing = await session.get(RepositoryRenameOperation, body.operation_id)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.repo_id != repo_id
|
||||
or existing.new_slug != body.new_slug
|
||||
or existing.actor != body.actor
|
||||
or body.confirmation
|
||||
!= confirmation_for(repo_id, existing.old_slug, existing.new_slug)
|
||||
):
|
||||
raise RenamePreconditionFailed(
|
||||
"Repository rename operation ID is already bound to another request"
|
||||
)
|
||||
return existing, True
|
||||
|
||||
token = _verify_preflight_token(body.preflight_token)
|
||||
if token.get("repo_id") != str(repo_id) or token.get("new_slug") != body.new_slug:
|
||||
raise RenamePreconditionFailed("Preflight token does not address this rename")
|
||||
|
|
@ -730,6 +745,7 @@ async def create_operation(
|
|||
forge = preflight["current"]["forge"]
|
||||
now = utcnow()
|
||||
operation = RepositoryRenameOperation(
|
||||
id=body.operation_id or uuid.uuid4(),
|
||||
repo_id=repo.id,
|
||||
forge_identity_id=identity.id,
|
||||
forge_identity_state="verified",
|
||||
|
|
@ -777,10 +793,33 @@ async def create_operation(
|
|||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
await session.rollback()
|
||||
if body.operation_id is not None:
|
||||
existing = await session.get(RepositoryRenameOperation, body.operation_id)
|
||||
if (
|
||||
existing is not None
|
||||
and existing.repo_id == repo_id
|
||||
and existing.new_slug == body.new_slug
|
||||
and existing.actor == body.actor
|
||||
and body.confirmation
|
||||
== confirmation_for(repo_id, existing.old_slug, existing.new_slug)
|
||||
):
|
||||
return existing, True
|
||||
raise RenamePreconditionFailed(
|
||||
"A conflicting repository rename operation was created"
|
||||
) from exc
|
||||
await session.refresh(operation)
|
||||
return operation, False
|
||||
|
||||
|
||||
async def load_operation_by_id(
|
||||
session: AsyncSession,
|
||||
operation_id: uuid.UUID,
|
||||
) -> RepositoryRenameOperation:
|
||||
operation = await session.get(RepositoryRenameOperation, operation_id)
|
||||
if operation is None:
|
||||
raise RenameNotFound(
|
||||
f"Repository rename operation {operation_id} was not found"
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import urllib.parse
|
|||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from repository_rename_cli import configure_repo_commands
|
||||
from statehub_register import run_register as run_statehub_register
|
||||
|
||||
STATE_HUB_DIR = Path(__file__).resolve().parent
|
||||
|
|
@ -728,6 +729,8 @@ def main() -> None:
|
|||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
configure_repo_commands(sub)
|
||||
|
||||
# register
|
||||
statehub_reg = sub.add_parser(
|
||||
"register",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ build-backend = "hatchling.build"
|
|||
packages = ["api", "mcp_server", "task_flow_engine"]
|
||||
artifacts = [
|
||||
"custodian_cli.py",
|
||||
"repository_rename_cli.py",
|
||||
"statehub_register.py",
|
||||
"scripts/consistency_check.py",
|
||||
"scripts/repo_sync.py",
|
||||
|
|
@ -41,6 +42,17 @@ artifacts = [
|
|||
"scripts/project_rules/*.template",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"custodian_cli.py" = "custodian_cli.py"
|
||||
"repository_rename_cli.py" = "repository_rename_cli.py"
|
||||
"statehub_register.py" = "statehub_register.py"
|
||||
"scripts/consistency_check.py" = "scripts/consistency_check.py"
|
||||
"scripts/ensure_gitignore_claude_rules.py" = "scripts/ensure_gitignore_claude_rules.py"
|
||||
"scripts/repo_sync.py" = "scripts/repo_sync.py"
|
||||
"scripts/mcp_registration.py" = "scripts/mcp_registration.py"
|
||||
"scripts/project_claude_md.template" = "scripts/project_claude_md.template"
|
||||
"scripts/project_rules" = "scripts/project_rules"
|
||||
|
||||
[tool.uv.sources]
|
||||
llm-connect = { path = "/home/worsch/llm-connect", editable = true }
|
||||
hub-core = { path = "/home/worsch/hub-core", editable = true }
|
||||
|
|
|
|||
835
repository_rename_cli.py
Normal file
835
repository_rename_cli.py
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
"""Non-interactive State Hub repository-rename orchestration CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
CLI_SCHEMA_VERSION = "state-hub.repository-rename-cli.v1"
|
||||
DEFAULT_API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
|
||||
FORWARD_PHASES = (
|
||||
"forge-renamed",
|
||||
"statehub-rebound",
|
||||
"source-synced",
|
||||
"consumers-verified",
|
||||
"completed",
|
||||
)
|
||||
NEXT_PHASE = {
|
||||
"preflighted": "forge-renamed",
|
||||
"forge-renamed": "statehub-rebound",
|
||||
"statehub-rebound": "source-synced",
|
||||
"source-synced": "consumers-verified",
|
||||
"consumers-verified": "completed",
|
||||
}
|
||||
_HEADERS = {"X-StateHub-Component": "state-hub.repository-rename-cli"}
|
||||
_SENSITIVE_KEYS = {
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"password",
|
||||
"secret",
|
||||
"api-key",
|
||||
"api_key",
|
||||
"access-token",
|
||||
"access_token",
|
||||
"refresh-token",
|
||||
"refresh_token",
|
||||
"credential",
|
||||
}
|
||||
|
||||
|
||||
class RenameCLIError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "rename_cli_error",
|
||||
status_code: int | None = None,
|
||||
details: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.details = details
|
||||
|
||||
|
||||
def _redact_text(value: str) -> str:
|
||||
value = re.sub(
|
||||
r"(https?://)[^/@\s]+:[^/@\s]+@",
|
||||
r"\1[REDACTED]@",
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
value = re.sub(
|
||||
r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+",
|
||||
r"\1 [REDACTED]",
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
value = re.sub(
|
||||
r"([?&][^=&#\s]*(?:api[_-]?key|password|secret|signature|token|credential)"
|
||||
r"[^=&#\s]*=)[^&#\s]+",
|
||||
r"\1[REDACTED]",
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _public(value: Any, *, redact_preflight_token: bool = True) -> Any:
|
||||
if isinstance(value, dict):
|
||||
result: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
normalized = key.lower()
|
||||
if normalized in _SENSITIVE_KEYS or any(
|
||||
marker in normalized
|
||||
for marker in ("password", "authorization", "credential")
|
||||
):
|
||||
result[key] = "[REDACTED]"
|
||||
elif key == "preflight_token" and redact_preflight_token and item:
|
||||
result[key] = "[REDACTED: use --output preflight file]"
|
||||
else:
|
||||
result[key] = _public(
|
||||
item, redact_preflight_token=redact_preflight_token
|
||||
)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
_public(item, redact_preflight_token=redact_preflight_token)
|
||||
for item in value
|
||||
]
|
||||
if isinstance(value, str):
|
||||
return _redact_text(value)
|
||||
return value
|
||||
|
||||
|
||||
def _api_request(
|
||||
api_base: str,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers = dict(_HEADERS)
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(
|
||||
api_base.rstrip("/") + path,
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
payload = json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
payload = json.loads(exc.read())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
payload = {}
|
||||
detail = payload.get("detail", payload) if isinstance(payload, dict) else {}
|
||||
if isinstance(detail, dict):
|
||||
message = str(detail.get("message") or f"State Hub returned HTTP {exc.code}")
|
||||
code = str(detail.get("code") or "state_hub_http_error")
|
||||
details = detail.get("details")
|
||||
else:
|
||||
message = str(detail or f"State Hub returned HTTP {exc.code}")
|
||||
code = "state_hub_http_error"
|
||||
details = None
|
||||
raise RenameCLIError(
|
||||
_redact_text(message),
|
||||
code=code,
|
||||
status_code=exc.code,
|
||||
details=_public(details),
|
||||
) from exc
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
raise RenameCLIError(
|
||||
"State Hub is unavailable",
|
||||
code="state_hub_unavailable",
|
||||
details={"reason": _redact_text(str(exc.reason if hasattr(exc, "reason") else exc))},
|
||||
) from exc
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise RenameCLIError(
|
||||
"State Hub returned an invalid JSON response",
|
||||
code="invalid_state_hub_response",
|
||||
) from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RenameCLIError(
|
||||
"State Hub returned a non-object response",
|
||||
code="invalid_state_hub_response",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _load_json(path: str, *, expected: type | tuple[type, ...]) -> Any:
|
||||
try:
|
||||
if path == "-":
|
||||
value = json.load(sys.stdin)
|
||||
else:
|
||||
target = Path(path)
|
||||
if target.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
raise RenameCLIError(
|
||||
f"Refusing non-private input file {target}; require mode 0600",
|
||||
code="unsafe_input_permissions",
|
||||
)
|
||||
value = json.loads(target.read_text(encoding="utf-8"))
|
||||
except RenameCLIError:
|
||||
raise
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RenameCLIError(
|
||||
f"Could not read JSON input {path}",
|
||||
code="invalid_json_input",
|
||||
details={"reason": _redact_text(str(exc))},
|
||||
) from exc
|
||||
if not isinstance(value, expected):
|
||||
names = (
|
||||
", ".join(item.__name__ for item in expected)
|
||||
if isinstance(expected, tuple)
|
||||
else expected.__name__
|
||||
)
|
||||
raise RenameCLIError(
|
||||
f"JSON input {path} must contain {names}", code="invalid_json_input"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _write_private_json(path: str, payload: dict[str, Any]) -> None:
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError as exc:
|
||||
raise RenameCLIError(
|
||||
f"Refusing to overwrite existing preflight file {target}",
|
||||
code="output_exists",
|
||||
) from exc
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
except Exception:
|
||||
target.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _confirmation(repo_id: str, old_slug: str, new_slug: str) -> str:
|
||||
return f"rename:{repo_id}:{old_slug}:{new_slug}"
|
||||
|
||||
|
||||
def _rollback_confirmation(operation_id: str) -> str:
|
||||
return f"rollback:{operation_id}"
|
||||
|
||||
|
||||
def _validated_uuid(value: str, *, field: str = "operation ID") -> str:
|
||||
try:
|
||||
return str(uuid.UUID(value))
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise RenameCLIError(
|
||||
f"{field} must be a UUID",
|
||||
code="invalid_operation_id",
|
||||
) from exc
|
||||
|
||||
|
||||
def _command(*parts: str | None) -> list[str]:
|
||||
return [part for part in parts if part is not None]
|
||||
|
||||
|
||||
def _next_for_operation(operation: dict[str, Any]) -> dict[str, Any]:
|
||||
operation_id = str(operation["id"])
|
||||
repo_id = str(operation["repo_id"])
|
||||
phase = str(operation["phase"])
|
||||
confirmation = _confirmation(
|
||||
repo_id, str(operation["old_slug"]), str(operation["new_slug"])
|
||||
)
|
||||
if phase in NEXT_PHASE:
|
||||
target = NEXT_PHASE[phase]
|
||||
command = _command(
|
||||
"statehub",
|
||||
"repo",
|
||||
"rename",
|
||||
"apply",
|
||||
operation_id,
|
||||
"--phase",
|
||||
target,
|
||||
"--confirm",
|
||||
confirmation,
|
||||
)
|
||||
if target in {"source-synced", "consumers-verified"}:
|
||||
command.extend(["--evidence-file", "<evidence.json>"])
|
||||
if target == "consumers-verified":
|
||||
command.extend(["--checks-file", "<checks.json>"])
|
||||
return {
|
||||
"action": "apply-phase",
|
||||
"phase": target,
|
||||
"expected_phase": phase,
|
||||
"requires_confirmation": True,
|
||||
"requires_evidence": target in {"source-synced", "consumers-verified"},
|
||||
"requires_checks": target == "consumers-verified",
|
||||
"command": command,
|
||||
}
|
||||
if phase == "rollback-preflight":
|
||||
return {
|
||||
"action": "execute-rollback",
|
||||
"requires_confirmation": True,
|
||||
"command": [
|
||||
"statehub",
|
||||
"repo",
|
||||
"rename",
|
||||
"rollback",
|
||||
operation_id,
|
||||
"--confirm",
|
||||
_rollback_confirmation(operation_id),
|
||||
"--execute",
|
||||
],
|
||||
}
|
||||
if phase == "completed":
|
||||
return {"action": "none", "reason": "rename completed", "command": None}
|
||||
if phase == "rolled-back":
|
||||
return {"action": "none", "reason": "rename rolled back", "command": None}
|
||||
return {
|
||||
"action": "inspect",
|
||||
"reason": f"unsupported or unknown phase {phase!r}",
|
||||
"command": ["statehub", "repo", "rename", "status", operation_id],
|
||||
}
|
||||
|
||||
|
||||
def _state_for_operation(operation: dict[str, Any]) -> str:
|
||||
if operation.get("phase") == "rolled-back":
|
||||
return "rolled-back"
|
||||
if operation.get("error_code"):
|
||||
return "failed"
|
||||
if operation.get("phase") == "completed":
|
||||
return "achieved"
|
||||
return "planned"
|
||||
|
||||
|
||||
def _envelope(
|
||||
command: str,
|
||||
*,
|
||||
state: str,
|
||||
ok: bool,
|
||||
result: dict[str, Any] | None = None,
|
||||
operation_id: str | None = None,
|
||||
repo_id: str | None = None,
|
||||
phase: str | None = None,
|
||||
next_safe_action: dict[str, Any] | None = None,
|
||||
error: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": CLI_SCHEMA_VERSION,
|
||||
"command": command,
|
||||
"ok": ok,
|
||||
"state": state,
|
||||
"operation_id": operation_id,
|
||||
"repo_id": repo_id,
|
||||
"phase": phase,
|
||||
"next_safe_action": next_safe_action,
|
||||
"result": result,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _emit(payload: dict[str, Any], *, as_json: bool) -> None:
|
||||
public = _public(payload)
|
||||
if as_json:
|
||||
print(json.dumps(public, indent=2, sort_keys=True))
|
||||
return
|
||||
state = str(public["state"]).upper()
|
||||
stream = sys.stderr if not public["ok"] else sys.stdout
|
||||
print(f"{state}: repository rename {public['command']}", file=stream)
|
||||
if public.get("operation_id"):
|
||||
print(f" operation: {public['operation_id']}", file=stream)
|
||||
if public.get("repo_id"):
|
||||
print(f" repository: {public['repo_id']}", file=stream)
|
||||
if public.get("phase"):
|
||||
print(f" phase: {public['phase']}", file=stream)
|
||||
result = public.get("result") or {}
|
||||
if public["command"] == "preflight":
|
||||
print(f" safe to apply: {bool(result.get('safe_to_apply'))}", file=stream)
|
||||
print(f" blockers: {len(result.get('blockers') or [])}", file=stream)
|
||||
if result.get("preflight_file"):
|
||||
print(f" private preflight: {result['preflight_file']}", file=stream)
|
||||
error = public.get("error")
|
||||
if error:
|
||||
print(f" error: {error.get('message')}", file=stream)
|
||||
action = public.get("next_safe_action") or {}
|
||||
if action.get("action"):
|
||||
print(f" next safe action: {action['action']}", file=stream)
|
||||
if action.get("command"):
|
||||
print(f" command: {shlex.join(action['command'])}", file=stream)
|
||||
|
||||
|
||||
def _run(args: argparse.Namespace, action: Callable[[], dict[str, Any]]) -> None:
|
||||
try:
|
||||
payload = action()
|
||||
except RenameCLIError as exc:
|
||||
payload = _envelope(
|
||||
args.rename_command,
|
||||
state="failed",
|
||||
ok=False,
|
||||
operation_id=getattr(args, "operation_id", None),
|
||||
error={
|
||||
"code": exc.code,
|
||||
"message": str(exc),
|
||||
"status_code": exc.status_code,
|
||||
"details": exc.details,
|
||||
},
|
||||
next_safe_action={"action": "inspect-input-or-status", "command": None},
|
||||
)
|
||||
_emit(payload, as_json=args.as_json)
|
||||
raise SystemExit(1) from exc
|
||||
_emit(payload, as_json=args.as_json)
|
||||
if not payload["ok"]:
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def _resolve_repo(args: argparse.Namespace, slug: str) -> dict[str, Any]:
|
||||
quoted = urllib.parse.quote(slug, safe="")
|
||||
repo = _api_request(args.api_base, "GET", f"/repos/{quoted}")
|
||||
if repo.get("slug_status") == "alias":
|
||||
raise RenameCLIError(
|
||||
f"{slug!r} is a prior alias; current canonical slug is {repo.get('canonical_slug')!r}",
|
||||
code="old_slug_is_alias",
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
def cmd_preflight(args: argparse.Namespace) -> None:
|
||||
def action() -> dict[str, Any]:
|
||||
operation_id = (
|
||||
_validated_uuid(args.operation_id)
|
||||
if args.operation_id
|
||||
else str(uuid.uuid4())
|
||||
)
|
||||
if args.output and Path(args.output).exists():
|
||||
raise RenameCLIError(
|
||||
f"Refusing to overwrite existing preflight file {args.output}",
|
||||
code="output_exists",
|
||||
)
|
||||
repo = _resolve_repo(args, args.old_slug)
|
||||
edge_writes: list[dict[str, Any]] = []
|
||||
if args.queued_edge_writes:
|
||||
edge_writes = _load_json(args.queued_edge_writes, expected=list)
|
||||
if not all(isinstance(item, dict) for item in edge_writes):
|
||||
raise RenameCLIError(
|
||||
"Queued edge-write input must be a list of objects",
|
||||
code="invalid_json_input",
|
||||
)
|
||||
report = _api_request(
|
||||
args.api_base,
|
||||
"POST",
|
||||
f"/repos/{repo['id']}/rename/preflight",
|
||||
{"new_slug": args.new_slug, "queued_edge_writes": edge_writes},
|
||||
)
|
||||
confirmation = _confirmation(str(repo["id"]), args.old_slug, args.new_slug)
|
||||
private = _envelope(
|
||||
"preflight",
|
||||
state="planned",
|
||||
ok=bool(report.get("safe_to_apply")),
|
||||
result=report,
|
||||
operation_id=operation_id,
|
||||
repo_id=str(repo["id"]),
|
||||
phase=None,
|
||||
)
|
||||
if args.output:
|
||||
_write_private_json(args.output, private)
|
||||
if report.get("safe_to_apply") and args.output:
|
||||
next_action = {
|
||||
"action": "start-operation",
|
||||
"requires_confirmation": True,
|
||||
"command": [
|
||||
"statehub", "repo", "rename", "start",
|
||||
args.old_slug, args.new_slug,
|
||||
"--operation-id", operation_id,
|
||||
"--preflight-file", args.output,
|
||||
"--actor", "<actor>",
|
||||
"--confirm", confirmation,
|
||||
],
|
||||
}
|
||||
elif report.get("safe_to_apply"):
|
||||
next_action = {
|
||||
"action": "save-private-preflight",
|
||||
"requires_confirmation": False,
|
||||
"command": [
|
||||
"statehub", "repo", "rename", "preflight",
|
||||
args.old_slug, args.new_slug,
|
||||
"--operation-id", operation_id,
|
||||
"--output", "<preflight-report.json>",
|
||||
],
|
||||
}
|
||||
else:
|
||||
next_action = {
|
||||
"action": "resolve-blockers",
|
||||
"requires_confirmation": False,
|
||||
"command": None,
|
||||
}
|
||||
public_report = dict(report)
|
||||
public_report["preflight_file"] = args.output
|
||||
return _envelope(
|
||||
"preflight",
|
||||
state="planned" if report.get("safe_to_apply") else "failed",
|
||||
ok=bool(report.get("safe_to_apply")),
|
||||
result=public_report,
|
||||
operation_id=operation_id,
|
||||
repo_id=str(repo["id"]),
|
||||
next_safe_action=next_action,
|
||||
)
|
||||
|
||||
_run(args, action)
|
||||
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> None:
|
||||
def action() -> dict[str, Any]:
|
||||
operation_id = _validated_uuid(args.operation_id)
|
||||
try:
|
||||
existing = _api_request(
|
||||
args.api_base,
|
||||
"GET",
|
||||
f"/repository-renames/operations/{operation_id}",
|
||||
)
|
||||
except RenameCLIError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
else:
|
||||
expected_confirmation = _confirmation(
|
||||
str(existing["repo_id"]),
|
||||
str(existing["old_slug"]),
|
||||
str(existing["new_slug"]),
|
||||
)
|
||||
if (
|
||||
existing.get("old_slug") != args.old_slug
|
||||
or existing.get("new_slug") != args.new_slug
|
||||
or existing.get("actor") != args.actor
|
||||
or args.confirm != expected_confirmation
|
||||
):
|
||||
raise RenameCLIError(
|
||||
"Operation ID is already bound to another repository rename",
|
||||
code="operation_id_conflict",
|
||||
)
|
||||
existing = {**existing, "no_op": True}
|
||||
return _operation_envelope("start", existing)
|
||||
|
||||
wrapper = _load_json(args.preflight_file, expected=dict)
|
||||
if (
|
||||
wrapper.get("schema_version") != CLI_SCHEMA_VERSION
|
||||
or wrapper.get("command") != "preflight"
|
||||
):
|
||||
raise RenameCLIError(
|
||||
"Preflight file is not a repository-rename CLI preflight",
|
||||
code="invalid_preflight_file",
|
||||
)
|
||||
if str(wrapper.get("operation_id")) != operation_id:
|
||||
raise RenameCLIError(
|
||||
"Preflight operation ID does not match --operation-id",
|
||||
code="invalid_preflight_file",
|
||||
)
|
||||
report = wrapper.get("result") or {}
|
||||
repo = _resolve_repo(args, args.old_slug)
|
||||
if (
|
||||
str(wrapper.get("repo_id")) != str(repo["id"])
|
||||
or report.get("old_slug") != args.old_slug
|
||||
or report.get("new_slug") != args.new_slug
|
||||
or not report.get("safe_to_apply")
|
||||
or not report.get("preflight_token")
|
||||
):
|
||||
raise RenameCLIError(
|
||||
"Preflight file does not authorize this repository rename",
|
||||
code="invalid_preflight_file",
|
||||
)
|
||||
expected_confirmation = _confirmation(
|
||||
str(repo["id"]), args.old_slug, args.new_slug
|
||||
)
|
||||
if args.confirm != expected_confirmation:
|
||||
raise RenameCLIError(
|
||||
"Explicit repository rename confirmation is incorrect",
|
||||
code="confirmation_mismatch",
|
||||
)
|
||||
operation = _api_request(
|
||||
args.api_base,
|
||||
"POST",
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
{
|
||||
"operation_id": operation_id,
|
||||
"new_slug": args.new_slug,
|
||||
"preflight_token": report["preflight_token"],
|
||||
"confirmation": args.confirm,
|
||||
"actor": args.actor,
|
||||
"queued_edge_writes": report.get("queued_edge_writes") or [],
|
||||
},
|
||||
)
|
||||
return _operation_envelope("start", operation)
|
||||
|
||||
_run(args, action)
|
||||
|
||||
|
||||
def _load_operation(args: argparse.Namespace) -> dict[str, Any]:
|
||||
operation_id = _validated_uuid(args.operation_id)
|
||||
return _api_request(
|
||||
args.api_base,
|
||||
"GET",
|
||||
f"/repository-renames/operations/{operation_id}",
|
||||
)
|
||||
|
||||
|
||||
def _operation_envelope(command: str, operation: dict[str, Any]) -> dict[str, Any]:
|
||||
return _envelope(
|
||||
command,
|
||||
state=_state_for_operation(operation),
|
||||
ok=not bool(operation.get("error_code")),
|
||||
result=operation,
|
||||
operation_id=str(operation["id"]),
|
||||
repo_id=str(operation["repo_id"]),
|
||||
phase=str(operation["phase"]),
|
||||
next_safe_action=_next_for_operation(operation),
|
||||
)
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
_run(args, lambda: _operation_envelope("status", _load_operation(args)))
|
||||
|
||||
|
||||
def cmd_apply(args: argparse.Namespace) -> None:
|
||||
def action() -> dict[str, Any]:
|
||||
operation = _load_operation(args)
|
||||
expected_confirmation = _confirmation(
|
||||
str(operation["repo_id"]),
|
||||
str(operation["old_slug"]),
|
||||
str(operation["new_slug"]),
|
||||
)
|
||||
if args.confirm != expected_confirmation:
|
||||
raise RenameCLIError(
|
||||
"Explicit repository rename confirmation is incorrect",
|
||||
code="confirmation_mismatch",
|
||||
)
|
||||
evidence = (
|
||||
_load_json(args.evidence_file, expected=dict)
|
||||
if args.evidence_file
|
||||
else {}
|
||||
)
|
||||
checks = (
|
||||
_load_json(args.checks_file, expected=dict)
|
||||
if args.checks_file
|
||||
else {}
|
||||
)
|
||||
if any(not isinstance(value, bool) for value in checks.values()):
|
||||
raise RenameCLIError(
|
||||
"Checks file values must all be booleans",
|
||||
code="invalid_json_input",
|
||||
)
|
||||
updated = _api_request(
|
||||
args.api_base,
|
||||
"POST",
|
||||
f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/phases/{args.phase}",
|
||||
{
|
||||
"expected_phase": args.expected_phase or operation["phase"],
|
||||
"confirmation": args.confirm,
|
||||
"checks": checks,
|
||||
"evidence": evidence,
|
||||
},
|
||||
)
|
||||
return _operation_envelope("apply", updated)
|
||||
|
||||
_run(args, action)
|
||||
|
||||
|
||||
def cmd_verify(args: argparse.Namespace) -> None:
|
||||
def action() -> dict[str, Any]:
|
||||
operation = _load_operation(args)
|
||||
verification = _api_request(
|
||||
args.api_base,
|
||||
"GET",
|
||||
f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/verify",
|
||||
)
|
||||
if verification.get("ok"):
|
||||
next_action = _next_for_operation(operation)
|
||||
state = "achieved"
|
||||
else:
|
||||
next_action = {
|
||||
"action": "resolve-verification-failures",
|
||||
"command": [
|
||||
"statehub", "repo", "rename", "status", str(operation["id"])
|
||||
],
|
||||
}
|
||||
state = "failed"
|
||||
return _envelope(
|
||||
"verify",
|
||||
state=state,
|
||||
ok=bool(verification.get("ok")),
|
||||
result=verification,
|
||||
operation_id=str(operation["id"]),
|
||||
repo_id=str(operation["repo_id"]),
|
||||
phase=str(operation["phase"]),
|
||||
next_safe_action=next_action,
|
||||
)
|
||||
|
||||
_run(args, action)
|
||||
|
||||
|
||||
def cmd_rollback(args: argparse.Namespace) -> None:
|
||||
def action() -> dict[str, Any]:
|
||||
operation = _load_operation(args)
|
||||
operation_id = str(operation["id"])
|
||||
expected_confirmation = _rollback_confirmation(operation_id)
|
||||
if args.confirm != expected_confirmation:
|
||||
raise RenameCLIError(
|
||||
"Explicit rollback confirmation is incorrect",
|
||||
code="confirmation_mismatch",
|
||||
)
|
||||
if operation["phase"] == "rolled-back":
|
||||
return _operation_envelope("rollback", operation)
|
||||
if operation["phase"] != "rollback-preflight":
|
||||
preflight = _api_request(
|
||||
args.api_base,
|
||||
"POST",
|
||||
f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback-preflight",
|
||||
{
|
||||
"expected_phase": args.expected_phase or operation["phase"],
|
||||
"confirmation": args.confirm,
|
||||
},
|
||||
)
|
||||
operation = preflight["operation"]
|
||||
if not preflight.get("safe_to_rollback"):
|
||||
raise RenameCLIError(
|
||||
"Repository rename rollback is unsafe",
|
||||
code="rollback_unsafe",
|
||||
details=preflight,
|
||||
)
|
||||
if not args.execute:
|
||||
return _operation_envelope("rollback", operation)
|
||||
rolled_back = _api_request(
|
||||
args.api_base,
|
||||
"POST",
|
||||
f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback",
|
||||
{"expected_phase": "rollback-preflight", "confirmation": args.confirm},
|
||||
)
|
||||
return _operation_envelope("rollback", rolled_back)
|
||||
|
||||
_run(args, action)
|
||||
|
||||
|
||||
def _common(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--api-base", default=DEFAULT_API_BASE, help="State Hub API base URL"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="as_json",
|
||||
help="Emit the stable orchestration JSON contract",
|
||||
)
|
||||
|
||||
|
||||
def configure_repo_commands(subparsers: argparse._SubParsersAction) -> None:
|
||||
repo = subparsers.add_parser("repo", help="Repository lifecycle commands")
|
||||
repo_sub = repo.add_subparsers(dest="repo_command", required=True)
|
||||
rename = repo_sub.add_parser(
|
||||
"rename", help="Governed repository rename lifecycle"
|
||||
)
|
||||
rename_sub = rename.add_subparsers(dest="rename_command", required=True)
|
||||
|
||||
preflight = rename_sub.add_parser(
|
||||
"preflight",
|
||||
help="Inspect rename safety without mutating State Hub or Forgejo",
|
||||
)
|
||||
preflight.add_argument("old_slug")
|
||||
preflight.add_argument("new_slug")
|
||||
preflight.add_argument(
|
||||
"--operation-id",
|
||||
default=None,
|
||||
help="Client-owned UUID; generated when omitted",
|
||||
)
|
||||
preflight.add_argument(
|
||||
"--queued-edge-writes",
|
||||
default=None,
|
||||
help="Mode-0600 JSON list of queued-write evidence",
|
||||
)
|
||||
preflight.add_argument(
|
||||
"--output",
|
||||
default=None,
|
||||
help="Write the token-bearing preflight envelope to a new mode-0600 file",
|
||||
)
|
||||
_common(preflight)
|
||||
preflight.set_defaults(func=cmd_preflight)
|
||||
|
||||
start = rename_sub.add_parser(
|
||||
"start",
|
||||
help="Create an idempotent operation journal from a private preflight file",
|
||||
)
|
||||
start.add_argument("old_slug")
|
||||
start.add_argument("new_slug")
|
||||
start.add_argument("--operation-id", required=True)
|
||||
start.add_argument(
|
||||
"--preflight-file",
|
||||
required=True,
|
||||
help="Mode-0600 preflight file, or - for stdin",
|
||||
)
|
||||
start.add_argument("--actor", required=True)
|
||||
start.add_argument("--confirm", required=True, metavar="CONFIRMATION")
|
||||
_common(start)
|
||||
start.set_defaults(func=cmd_start)
|
||||
|
||||
apply = rename_sub.add_parser(
|
||||
"apply", help="Apply exactly one compare-and-set lifecycle phase"
|
||||
)
|
||||
apply.add_argument("operation_id")
|
||||
apply.add_argument("--phase", required=True, choices=FORWARD_PHASES)
|
||||
apply.add_argument("--expected-phase", default=None)
|
||||
apply.add_argument("--confirm", required=True, metavar="CONFIRMATION")
|
||||
apply.add_argument(
|
||||
"--evidence-file",
|
||||
default=None,
|
||||
help="Mode-0600 JSON object, or - for stdin",
|
||||
)
|
||||
apply.add_argument(
|
||||
"--checks-file", default=None, help="Mode-0600 JSON boolean map"
|
||||
)
|
||||
_common(apply)
|
||||
apply.set_defaults(func=cmd_apply)
|
||||
|
||||
status_parser = rename_sub.add_parser(
|
||||
"status", help="Read the durable operation journal"
|
||||
)
|
||||
status_parser.add_argument("operation_id")
|
||||
_common(status_parser)
|
||||
status_parser.set_defaults(func=cmd_status)
|
||||
|
||||
verify = rename_sub.add_parser(
|
||||
"verify", help="Verify Forge identity and State Hub continuity"
|
||||
)
|
||||
verify.add_argument("operation_id")
|
||||
_common(verify)
|
||||
verify.set_defaults(func=cmd_verify)
|
||||
|
||||
rollback = rename_sub.add_parser(
|
||||
"rollback",
|
||||
help="Preflight rollback; --execute is required to perform it",
|
||||
)
|
||||
rollback.add_argument("operation_id")
|
||||
rollback.add_argument("--expected-phase", default=None)
|
||||
rollback.add_argument("--confirm", required=True, metavar="CONFIRMATION")
|
||||
rollback.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="Execute after rollback preflight succeeds",
|
||||
)
|
||||
_common(rollback)
|
||||
rollback.set_defaults(func=cmd_rollback)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLI_SCHEMA_VERSION",
|
||||
"RenameCLIError",
|
||||
"configure_repo_commands",
|
||||
]
|
||||
|
|
@ -154,6 +154,55 @@ async def test_dry_run_has_no_persistent_changes(client, test_engine, rename_set
|
|||
assert after == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_operation_id_is_idempotent_and_globally_discoverable(
|
||||
client, rename_setup
|
||||
):
|
||||
repo, _forge = rename_setup
|
||||
preflight = await _preflight(client, repo["id"])
|
||||
operation_id = str(uuid.uuid4())
|
||||
confirmation = f"rename:{repo['id']}:flex-auth:access-engine"
|
||||
payload = {
|
||||
"operation_id": operation_id,
|
||||
"new_slug": "access-engine",
|
||||
"preflight_token": preflight["preflight_token"],
|
||||
"confirmation": confirmation,
|
||||
"actor": "helixforge-test",
|
||||
}
|
||||
|
||||
created = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations", json=payload
|
||||
)
|
||||
replay = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={**payload, "preflight_token": "expired-after-creation"},
|
||||
)
|
||||
assert created.status_code == replay.status_code == 201
|
||||
assert created.json()["id"] == replay.json()["id"] == operation_id
|
||||
assert created.json()["no_op"] is False
|
||||
assert replay.json()["no_op"] is True
|
||||
|
||||
discovered = await client.get(
|
||||
f"/repository-renames/operations/{operation_id}"
|
||||
)
|
||||
assert discovered.status_code == 200
|
||||
assert discovered.json()["repo_id"] == repo["id"]
|
||||
assert discovered.json()["phase"] == "preflighted"
|
||||
|
||||
collision = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={**payload, "new_slug": "another-name"},
|
||||
)
|
||||
assert collision.status_code == 412
|
||||
assert collision.json()["detail"]["code"] == "repository_rename_precondition_failed"
|
||||
|
||||
actor_collision = await client.post(
|
||||
f"/repos/{repo['id']}/rename/operations",
|
||||
json={**payload, "actor": "different-actor"},
|
||||
)
|
||||
assert actor_collision.status_code == 412
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup):
|
||||
repo, forge = rename_setup
|
||||
|
|
|
|||
390
tests/test_repository_rename_cli.py
Normal file
390
tests/test_repository_rename_cli.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
import custodian_cli
|
||||
import repository_rename_cli as rename_cli
|
||||
|
||||
|
||||
REPO_ID = "11111111-1111-4111-8111-111111111111"
|
||||
OPERATION_ID = "22222222-2222-4222-8222-222222222222"
|
||||
CONFIRMATION = f"rename:{REPO_ID}:flex-auth:access-engine"
|
||||
|
||||
|
||||
def _repo() -> dict:
|
||||
return {
|
||||
"id": REPO_ID,
|
||||
"slug": "flex-auth",
|
||||
"canonical_slug": "flex-auth",
|
||||
"requested_slug": "flex-auth",
|
||||
"slug_status": "canonical",
|
||||
}
|
||||
|
||||
|
||||
def _operation(phase: str, *, no_op: bool = False) -> dict:
|
||||
return {
|
||||
"id": OPERATION_ID,
|
||||
"repo_id": REPO_ID,
|
||||
"phase": phase,
|
||||
"old_slug": "flex-auth",
|
||||
"new_slug": "access-engine",
|
||||
"actor": "helixforge",
|
||||
"error_code": None,
|
||||
"error_message": None,
|
||||
"no_op": no_op,
|
||||
}
|
||||
|
||||
|
||||
def _preflight() -> dict:
|
||||
return {
|
||||
"schema_version": "state-hub.repository-rename-preflight.v1",
|
||||
"repo_id": REPO_ID,
|
||||
"old_slug": "flex-auth",
|
||||
"new_slug": "access-engine",
|
||||
"safe_to_apply": True,
|
||||
"blockers": [],
|
||||
"warnings": [],
|
||||
"queued_edge_writes": [],
|
||||
"preflight_token": "signed-preflight-token",
|
||||
}
|
||||
|
||||
|
||||
def _run(monkeypatch, *args: str) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["statehub", *args])
|
||||
custodian_cli.main()
|
||||
|
||||
|
||||
def test_preflight_writes_private_token_file_and_redacts_stdout(
|
||||
monkeypatch, tmp_path, capsys
|
||||
):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if method == "GET":
|
||||
return _repo()
|
||||
return _preflight()
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
output = tmp_path / "preflight.json"
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "preflight", "flex-auth", "access-engine",
|
||||
"--operation-id", OPERATION_ID,
|
||||
"--output", str(output),
|
||||
"--json",
|
||||
)
|
||||
|
||||
public = json.loads(capsys.readouterr().out)
|
||||
private = json.loads(output.read_text())
|
||||
assert public["schema_version"] == rename_cli.CLI_SCHEMA_VERSION
|
||||
assert public["operation_id"] == OPERATION_ID
|
||||
assert public["next_safe_action"]["action"] == "start-operation"
|
||||
assert public["result"]["preflight_token"].startswith("[REDACTED")
|
||||
assert "signed-preflight-token" not in json.dumps(public)
|
||||
assert private["result"]["preflight_token"] == "signed-preflight-token"
|
||||
assert stat.S_IMODE(output.stat().st_mode) == 0o600
|
||||
assert calls[1][2] == {
|
||||
"new_slug": "access-engine",
|
||||
"queued_edge_writes": [],
|
||||
}
|
||||
|
||||
|
||||
def test_start_uses_client_operation_id_and_retry_safe_api_body(
|
||||
monkeypatch, tmp_path, capsys
|
||||
):
|
||||
preflight_file = tmp_path / "preflight.json"
|
||||
preflight_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": rename_cli.CLI_SCHEMA_VERSION,
|
||||
"command": "preflight",
|
||||
"operation_id": OPERATION_ID,
|
||||
"repo_id": REPO_ID,
|
||||
"result": _preflight(),
|
||||
}
|
||||
)
|
||||
)
|
||||
preflight_file.chmod(0o600)
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if path.startswith("/repository-renames/"):
|
||||
raise rename_cli.RenameCLIError(
|
||||
"not found", code="repository_rename_not_found", status_code=404
|
||||
)
|
||||
return _repo() if method == "GET" else _operation("preflighted")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "start", "flex-auth", "access-engine",
|
||||
"--operation-id", OPERATION_ID,
|
||||
"--preflight-file", str(preflight_file),
|
||||
"--actor", "helixforge",
|
||||
"--confirm", CONFIRMATION,
|
||||
"--json",
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["phase"] == "preflighted"
|
||||
assert result["next_safe_action"]["phase"] == "forge-renamed"
|
||||
create_body = calls[2][2]
|
||||
assert create_body["operation_id"] == OPERATION_ID
|
||||
assert create_body["preflight_token"] == "signed-preflight-token"
|
||||
assert create_body["actor"] == "helixforge"
|
||||
assert "signed-preflight-token" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_start_retry_uses_operation_journal_after_old_slug_becomes_alias(
|
||||
monkeypatch, capsys
|
||||
):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
return _operation("statehub-rebound")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo",
|
||||
"rename",
|
||||
"start",
|
||||
"flex-auth",
|
||||
"access-engine",
|
||||
"--operation-id",
|
||||
OPERATION_ID,
|
||||
"--preflight-file",
|
||||
"/already-consumed/preflight.json",
|
||||
"--actor",
|
||||
"helixforge",
|
||||
"--confirm",
|
||||
CONFIRMATION,
|
||||
"--json",
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["phase"] == "statehub-rebound"
|
||||
assert result["result"]["no_op"] is True
|
||||
assert calls == [
|
||||
(
|
||||
"GET",
|
||||
f"/repository-renames/operations/{OPERATION_ID}",
|
||||
None,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_apply_uses_files_and_names_the_next_safe_phase(
|
||||
monkeypatch, tmp_path, capsys
|
||||
):
|
||||
evidence = tmp_path / "evidence.json"
|
||||
evidence.write_text('{"fresh_clone": true}')
|
||||
evidence.chmod(0o600)
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if method == "GET":
|
||||
return _operation("statehub-rebound")
|
||||
return _operation("source-synced")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "apply", OPERATION_ID,
|
||||
"--phase", "source-synced",
|
||||
"--confirm", CONFIRMATION,
|
||||
"--evidence-file", str(evidence),
|
||||
"--json",
|
||||
)
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["state"] == "planned"
|
||||
assert result["phase"] == "source-synced"
|
||||
assert result["next_safe_action"]["phase"] == "consumers-verified"
|
||||
apply_body = calls[1][2]
|
||||
assert apply_body["expected_phase"] == "statehub-rebound"
|
||||
assert apply_body["evidence"] == {"fresh_clone": True}
|
||||
|
||||
|
||||
def test_rollback_requires_separate_execute_flag(monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
if method == "GET":
|
||||
return _operation("source-synced")
|
||||
return {
|
||||
"safe_to_rollback": True,
|
||||
"operation": _operation("rollback-preflight"),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "rollback", OPERATION_ID,
|
||||
"--confirm", f"rollback:{OPERATION_ID}",
|
||||
"--json",
|
||||
)
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["phase"] == "rollback-preflight"
|
||||
assert result["next_safe_action"]["action"] == "execute-rollback"
|
||||
assert [method for method, _path, _body in calls] == ["GET", "POST"]
|
||||
|
||||
|
||||
def test_rollback_execute_reaches_terminal_state(monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
return (
|
||||
_operation("rollback-preflight")
|
||||
if method == "GET"
|
||||
else _operation("rolled-back")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "rollback", OPERATION_ID,
|
||||
"--confirm", f"rollback:{OPERATION_ID}",
|
||||
"--execute",
|
||||
"--json",
|
||||
)
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["state"] == "rolled-back"
|
||||
assert result["next_safe_action"]["action"] == "none"
|
||||
assert [method for method, _path, _body in calls] == ["GET", "POST"]
|
||||
|
||||
|
||||
def test_json_error_output_redacts_credentials_and_userinfo(monkeypatch, capsys):
|
||||
def request(*_args, **_kwargs):
|
||||
raise rename_cli.RenameCLIError(
|
||||
"failed via https://user:password@forge.example/repo"
|
||||
"?X-Amz-Credential=url-credential&X-Amz-Signature=url-secret",
|
||||
code="simulated",
|
||||
details={"authorization": "Bearer top-secret", "password": "hidden"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "status", OPERATION_ID, "--json",
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
output = capsys.readouterr().out
|
||||
assert "user:password" not in output
|
||||
assert "top-secret" not in output
|
||||
assert "hidden" not in output
|
||||
assert "url-secret" not in output
|
||||
assert "url-credential" not in output
|
||||
assert "[REDACTED]" in output
|
||||
|
||||
|
||||
def test_invalid_confirmation_stops_before_mutating_request(monkeypatch, capsys):
|
||||
calls = []
|
||||
|
||||
def request(_api_base, method, path, body=None):
|
||||
calls.append((method, path, body))
|
||||
return _operation("preflighted")
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo", "rename", "apply", OPERATION_ID,
|
||||
"--phase", "forge-renamed",
|
||||
"--confirm", "yes",
|
||||
"--json",
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
assert [method for method, _path, _body in calls] == ["GET"]
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["error"]["code"] == "confirmation_mismatch"
|
||||
|
||||
|
||||
def test_unsafe_preflight_is_machine_detectable(monkeypatch, capsys):
|
||||
def request(_api_base, method, _path, body=None):
|
||||
del body
|
||||
if method == "GET":
|
||||
return _repo()
|
||||
return {
|
||||
**_preflight(),
|
||||
"safe_to_apply": False,
|
||||
"blockers": [{"code": "target_exists"}],
|
||||
"preflight_token": None,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(rename_cli, "_api_request", request)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo",
|
||||
"rename",
|
||||
"preflight",
|
||||
"flex-auth",
|
||||
"access-engine",
|
||||
"--json",
|
||||
)
|
||||
assert exc.value.code == 2
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["state"] == "failed"
|
||||
assert result["next_safe_action"]["action"] == "resolve-blockers"
|
||||
|
||||
|
||||
def test_invalid_operation_id_is_stable_json(monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
rename_cli,
|
||||
"_api_request",
|
||||
lambda *_args, **_kwargs: pytest.fail("API should not be called"),
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(
|
||||
monkeypatch,
|
||||
"repo",
|
||||
"rename",
|
||||
"status",
|
||||
"not-a-uuid",
|
||||
"--json",
|
||||
)
|
||||
assert exc.value.code == 1
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["schema_version"] == rename_cli.CLI_SCHEMA_VERSION
|
||||
assert result["state"] == "failed"
|
||||
assert result["error"]["code"] == "invalid_operation_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("operation", "label"),
|
||||
[
|
||||
(_operation("preflighted"), "PLANNED"),
|
||||
(_operation("completed"), "ACHIEVED"),
|
||||
({**_operation("source-synced"), "error_code": "failed"}, "FAILED"),
|
||||
(_operation("rolled-back"), "ROLLED-BACK"),
|
||||
],
|
||||
)
|
||||
def test_human_status_distinguishes_lifecycle_states(
|
||||
monkeypatch, capsys, operation, label
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
rename_cli,
|
||||
"_api_request",
|
||||
lambda *_args, **_kwargs: operation,
|
||||
)
|
||||
if label == "FAILED":
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_run(monkeypatch, "repo", "rename", "status", OPERATION_ID)
|
||||
assert exc.value.code == 2
|
||||
else:
|
||||
_run(monkeypatch, "repo", "rename", "status", OPERATION_ID)
|
||||
captured = capsys.readouterr()
|
||||
assert f"{label}: repository rename status" in captured.out + captured.err
|
||||
|
|
@ -346,7 +346,7 @@ pass (the build retains one pre-existing `/docs/intakes` broken-link warning).
|
|||
|
||||
```task
|
||||
id: STATE-WP-0085-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "d85da2ea-8037-5117-a41a-d400ca68bf10"
|
||||
```
|
||||
|
|
@ -376,6 +376,26 @@ Acceptance:
|
|||
- CLI retries preserve operation identity;
|
||||
- no command logs credentials, authorization headers, or secret-bearing URLs.
|
||||
|
||||
Result (2026-08-29): added `statehub repo rename`
|
||||
`preflight`/`start`/`apply`/`status`/`verify`/`rollback` commands over the
|
||||
repository-ID-addressed lifecycle. The versioned
|
||||
`state-hub.repository-rename-cli.v1` JSON envelope reports lifecycle state and
|
||||
the next safe command; human output distinguishes planned, achieved, failed,
|
||||
and rolled-back operations. Preflight tokens are never printed and are passed
|
||||
to `start` through an exclusive mode-0600 JSON file (or stdin). Evidence and
|
||||
check inputs receive the same private-file guard, credential-bearing response
|
||||
fields and URLs are redacted, and no Forgejo credential option exists.
|
||||
Mutations require a client-owned operation UUID plus the exact rename or
|
||||
rollback confirmation. State Hub accepts and globally resolves that UUID;
|
||||
same-intent creation retries remain no-ops after token expiry or a canonical
|
||||
slug rebind, while changed target/actor intent fails closed. Rollback preflight
|
||||
and execution are deliberately separate. Wheel force-includes make the
|
||||
declared `statehub` entry point installable outside the checkout. Verification:
|
||||
789 repository tests and the 70-page dashboard build passed before the final
|
||||
retry hardening; the resulting focused suites pass 14 CLI and 7 API tests, and
|
||||
a clean wheel installation exposes the complete rename command tree. The
|
||||
dashboard retains the pre-existing `/docs/intakes` broken-link warning.
|
||||
|
||||
## Generate a target-repository migration workplan
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue