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-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-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-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-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-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 |
|
| 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(consistency_sweep.router)
|
||||||
app.include_router(repos.router)
|
app.include_router(repos.router)
|
||||||
app.include_router(repository_renames.router)
|
app.include_router(repository_renames.router)
|
||||||
|
app.include_router(repository_renames.operation_router)
|
||||||
app.include_router(topics.router)
|
app.include_router(topics.router)
|
||||||
app.include_router(workstreams.router)
|
app.include_router(workstreams.router)
|
||||||
app.include_router(workstreams.workplan_router)
|
app.include_router(workstreams.workplan_router)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from api.services.repository_rename import (
|
||||||
create_operation,
|
create_operation,
|
||||||
list_operations,
|
list_operations,
|
||||||
load_operation,
|
load_operation,
|
||||||
|
load_operation_by_id,
|
||||||
rollback_preflight,
|
rollback_preflight,
|
||||||
verify_forge_identity,
|
verify_forge_identity,
|
||||||
verify_operation,
|
verify_operation,
|
||||||
|
|
@ -38,6 +39,9 @@ from api.services.repository_rename import (
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
|
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
|
||||||
|
operation_router = APIRouter(
|
||||||
|
prefix="/repository-renames", tags=["repository-renames"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _raise(exc: RenameLifecycleError) -> None:
|
def _raise(exc: RenameLifecycleError) -> None:
|
||||||
|
|
@ -122,7 +126,22 @@ async def create_repository_rename_operation(
|
||||||
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
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:
|
except RenameLifecycleError as exc:
|
||||||
_raise(exc)
|
_raise(exc)
|
||||||
return _operation_read(operation)
|
return _operation_read(operation)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ class ForgeIdentityVerifyRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class RepositoryRenameOperationCreate(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-]*$")
|
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||||
preflight_token: str
|
preflight_token: str
|
||||||
confirmation: str
|
confirmation: str
|
||||||
|
|
|
||||||
|
|
@ -695,7 +695,22 @@ async def create_operation(
|
||||||
gateway: ForgeRepositoryGateway,
|
gateway: ForgeRepositoryGateway,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
body: RepositoryRenameOperationCreate,
|
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)
|
token = _verify_preflight_token(body.preflight_token)
|
||||||
if token.get("repo_id") != str(repo_id) or token.get("new_slug") != body.new_slug:
|
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")
|
raise RenamePreconditionFailed("Preflight token does not address this rename")
|
||||||
|
|
@ -730,6 +745,7 @@ async def create_operation(
|
||||||
forge = preflight["current"]["forge"]
|
forge = preflight["current"]["forge"]
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
operation = RepositoryRenameOperation(
|
operation = RepositoryRenameOperation(
|
||||||
|
id=body.operation_id or uuid.uuid4(),
|
||||||
repo_id=repo.id,
|
repo_id=repo.id,
|
||||||
forge_identity_id=identity.id,
|
forge_identity_id=identity.id,
|
||||||
forge_identity_state="verified",
|
forge_identity_state="verified",
|
||||||
|
|
@ -777,10 +793,33 @@ async def create_operation(
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
await session.rollback()
|
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(
|
raise RenamePreconditionFailed(
|
||||||
"A conflicting repository rename operation was created"
|
"A conflicting repository rename operation was created"
|
||||||
) from exc
|
) from exc
|
||||||
await session.refresh(operation)
|
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
|
return operation
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from repository_rename_cli import configure_repo_commands
|
||||||
from statehub_register import run_register as run_statehub_register
|
from statehub_register import run_register as run_statehub_register
|
||||||
|
|
||||||
STATE_HUB_DIR = Path(__file__).resolve().parent
|
STATE_HUB_DIR = Path(__file__).resolve().parent
|
||||||
|
|
@ -728,6 +729,8 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
configure_repo_commands(sub)
|
||||||
|
|
||||||
# register
|
# register
|
||||||
statehub_reg = sub.add_parser(
|
statehub_reg = sub.add_parser(
|
||||||
"register",
|
"register",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ build-backend = "hatchling.build"
|
||||||
packages = ["api", "mcp_server", "task_flow_engine"]
|
packages = ["api", "mcp_server", "task_flow_engine"]
|
||||||
artifacts = [
|
artifacts = [
|
||||||
"custodian_cli.py",
|
"custodian_cli.py",
|
||||||
|
"repository_rename_cli.py",
|
||||||
"statehub_register.py",
|
"statehub_register.py",
|
||||||
"scripts/consistency_check.py",
|
"scripts/consistency_check.py",
|
||||||
"scripts/repo_sync.py",
|
"scripts/repo_sync.py",
|
||||||
|
|
@ -41,6 +42,17 @@ artifacts = [
|
||||||
"scripts/project_rules/*.template",
|
"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]
|
[tool.uv.sources]
|
||||||
llm-connect = { path = "/home/worsch/llm-connect", editable = true }
|
llm-connect = { path = "/home/worsch/llm-connect", editable = true }
|
||||||
hub-core = { path = "/home/worsch/hub-core", 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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup):
|
async def test_interrupt_resume_every_phase_and_preserve_uuid(client, rename_setup):
|
||||||
repo, forge = 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
|
```task
|
||||||
id: STATE-WP-0085-T05
|
id: STATE-WP-0085-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d85da2ea-8037-5117-a41a-d400ca68bf10"
|
state_hub_task_id: "d85da2ea-8037-5117-a41a-d400ca68bf10"
|
||||||
```
|
```
|
||||||
|
|
@ -376,6 +376,26 @@ Acceptance:
|
||||||
- CLI retries preserve operation identity;
|
- CLI retries preserve operation identity;
|
||||||
- no command logs credentials, authorization headers, or secret-bearing URLs.
|
- 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
|
## Generate a target-repository migration workplan
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue