Address REQUEST_CHANGES on PR #688: - migrate_profiles: emit fully canonical reconciler/author/reviewer defaults; canonicalize explicit ops; fail if reconciler loses required pr.close/read - gitea_delete_branch: deny reconciler (and non-author roles); refuse preservation/protected branches before any API call - gitea_cleanup_merged_pr_branch: require reconciler role only; block preservation/evidence branches via branch_cleanup_guard - docs: end-to-end operator runbook for profile migrate/apply/reconnect/cleanup - tests: migration canonicalization, idempotency, raw-delete denial, guarded cleanup success and unmerged/preserve rejections Closes #687.
406 lines
14 KiB
Python
Executable File
406 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Migration helper to convert profiles.json from version 1 to version 2 environments shape.
|
|
|
|
This script preserves existing keychain references (auth.id) and maps old profile
|
|
names as aliases so that existing IDE configurations continue to function.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import shutil
|
|
import tempfile
|
|
|
|
# Resolve path to import gitea_config
|
|
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
if PROJECT_ROOT not in sys.path:
|
|
sys.path.insert(0, PROJECT_ROOT)
|
|
|
|
import gitea_config
|
|
|
|
|
|
# Defaults emit *canonical* operation names only. Shorthand that is not in
|
|
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``)
|
|
# is silently dropped by the production loader and must never appear here.
|
|
AUTHOR_DEFAULT_ALLOWED = [
|
|
"gitea.read",
|
|
"gitea.branch.create",
|
|
"gitea.repo.commit",
|
|
"gitea.branch.push",
|
|
"gitea.pr.create",
|
|
"gitea.pr.comment",
|
|
]
|
|
AUTHOR_DEFAULT_FORBIDDEN = [
|
|
"gitea.pr.approve",
|
|
"gitea.pr.request_changes",
|
|
"gitea.pr.merge",
|
|
]
|
|
REVIEWER_DEFAULT_ALLOWED = [
|
|
"gitea.read",
|
|
"gitea.pr.review",
|
|
"gitea.pr.comment",
|
|
"gitea.pr.approve",
|
|
"gitea.pr.request_changes",
|
|
"gitea.pr.merge",
|
|
]
|
|
REVIEWER_DEFAULT_FORBIDDEN = [
|
|
"gitea.branch.create",
|
|
"gitea.repo.commit",
|
|
"gitea.branch.push",
|
|
"gitea.pr.create",
|
|
]
|
|
# Required reconciler ops (read + pr.close) plus recommended comment/close and
|
|
# branch.delete for guarded merged-PR cleanup. All names must normalize via
|
|
# gitea_config.normalize_operation without being dropped.
|
|
RECONCILER_DEFAULT_ALLOWED = [
|
|
"gitea.read",
|
|
"gitea.pr.close",
|
|
"gitea.pr.comment",
|
|
"gitea.issue.comment",
|
|
"gitea.issue.close",
|
|
"gitea.branch.delete",
|
|
]
|
|
RECONCILER_DEFAULT_FORBIDDEN = [
|
|
"gitea.pr.approve",
|
|
"gitea.pr.merge",
|
|
"gitea.pr.review",
|
|
"gitea.pr.create",
|
|
"gitea.branch.push",
|
|
"gitea.repo.commit",
|
|
]
|
|
|
|
# Migration-only expansions for common shorthands that are *not* in
|
|
# GITEA_OPERATION_ALIASES. Emitted output is always the canonical form so a
|
|
# second canonicalize pass is a no-op (idempotent).
|
|
_MIGRATION_ONLY_ALIASES = {
|
|
"pr.close": "gitea.pr.close",
|
|
"pr.comment": "gitea.pr.comment",
|
|
"issue.close": "gitea.issue.close",
|
|
"branch.delete": "gitea.branch.delete",
|
|
}
|
|
|
|
# Reconciler required ops that must survive migration (from reconciler_profile).
|
|
RECONCILER_REQUIRED_CANONICAL = ("gitea.read", "gitea.pr.close")
|
|
|
|
|
|
def canonicalize_operation(op: str) -> str:
|
|
"""Return a canonical operation name accepted by the production loader.
|
|
|
|
Fail closed on unknown/ambiguous spellings so required permissions cannot
|
|
be silently dropped by ``check_operation`` later.
|
|
"""
|
|
if not isinstance(op, str) or not op.strip():
|
|
raise ValueError("operation must be a non-empty string (fail closed)")
|
|
op = op.strip()
|
|
try:
|
|
return gitea_config.normalize_operation(op)
|
|
except gitea_config.ConfigError:
|
|
pass
|
|
if op in _MIGRATION_ONLY_ALIASES:
|
|
return _MIGRATION_ONLY_ALIASES[op]
|
|
raise ValueError(
|
|
f"operation {op!r} cannot be canonicalized for migration "
|
|
"(unknown/ambiguous; fail closed — production loader would drop it)"
|
|
)
|
|
|
|
|
|
def canonicalize_operations(ops, *, context: str = "operations") -> list[str]:
|
|
"""Canonicalize a list of operations; preserve order, drop duplicates."""
|
|
if not isinstance(ops, list):
|
|
raise ValueError(f"{context} must be a list (fail closed)")
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for entry in ops:
|
|
canon = canonicalize_operation(entry)
|
|
if canon not in seen:
|
|
seen.add(canon)
|
|
out.append(canon)
|
|
return out
|
|
|
|
|
|
def _assert_reconciler_required_survive(allowed: list[str], profile_name: str) -> None:
|
|
"""Fail visibly when migration would leave a reconciler without required ops."""
|
|
missing = [op for op in RECONCILER_REQUIRED_CANONICAL if op not in set(allowed)]
|
|
if missing:
|
|
raise ValueError(
|
|
f"Profile '{profile_name}' (reconciler) is missing required "
|
|
f"operation(s) after migration: {missing}. Refusing to emit a "
|
|
"profile that would silently fail pr.close / read (fail closed)."
|
|
)
|
|
|
|
|
|
def infer_role(name, execution_profile):
|
|
"""Return the unambiguous role for a legacy profile name, or None."""
|
|
haystack = f"{name} {execution_profile or ''}".lower()
|
|
if "reconciler" in haystack:
|
|
return "reconciler"
|
|
has_author = "author" in haystack
|
|
has_reviewer = "reviewer" in haystack
|
|
if has_author == has_reviewer:
|
|
return None
|
|
return "reviewer" if has_reviewer else "author"
|
|
|
|
|
|
def migration_summary(v2_data):
|
|
"""Return a redacted summary of the migrated config."""
|
|
environments = v2_data.get("environments", {})
|
|
service_count = 0
|
|
identity_count = 0
|
|
for env in environments.values():
|
|
services = env.get("services", {})
|
|
service_count += len(services)
|
|
for service in services.values():
|
|
identity_count += len(service.get("identities", {}))
|
|
return {
|
|
"version": v2_data.get("version"),
|
|
"environments": len(environments),
|
|
"services": service_count,
|
|
"identities": identity_count,
|
|
"aliases": len(v2_data.get("aliases", {})),
|
|
}
|
|
|
|
|
|
def migrate_v1_to_v2(v1_data):
|
|
"""Convert version 1 profiles.json format to version 2 environments format."""
|
|
environments = {}
|
|
aliases = {}
|
|
|
|
profiles = v1_data.get("profiles", {})
|
|
if not isinstance(profiles, dict):
|
|
raise ValueError("Malformed input: 'profiles' field must be a JSON object")
|
|
|
|
for name, prof in profiles.items():
|
|
if not isinstance(prof, dict):
|
|
raise ValueError(f"Malformed input: profile '{name}' must be a JSON object")
|
|
|
|
# Infer environment and identity name
|
|
if "-" in name:
|
|
parts = name.split("-", 1)
|
|
env_name = parts[0]
|
|
ident_name = parts[1]
|
|
else:
|
|
env_name = name
|
|
ident_name = "author"
|
|
|
|
# Determine role and identity based on name / execution_profile.
|
|
# Ambiguous profiles may still migrate only when they carry explicit
|
|
# permissions; otherwise role-based defaults could widen permissions.
|
|
exec_prof = prof.get("execution_profile") or ""
|
|
role = infer_role(name, exec_prof)
|
|
if role == "reviewer":
|
|
ident_name = "reviewer"
|
|
elif role == "author":
|
|
ident_name = "author"
|
|
elif role == "reconciler":
|
|
ident_name = "reconciler"
|
|
else:
|
|
role = prof.get("role")
|
|
if role not in (None, "author", "reviewer", "reconciler"):
|
|
raise ValueError(
|
|
f"Profile '{name}' has unsupported role {role!r}"
|
|
)
|
|
|
|
# Construct identity block
|
|
identity_data = {
|
|
"username": prof.get("username"),
|
|
"auth": prof.get("auth"),
|
|
}
|
|
if role:
|
|
identity_data["role"] = role
|
|
if prof.get("execution_profile"):
|
|
identity_data["execution_profile"] = prof["execution_profile"]
|
|
|
|
# Set audit label (default to old name to preserve context)
|
|
identity_data["audit_label"] = prof.get("audit_label") or name
|
|
|
|
has_allowed = "allowed_operations" in prof
|
|
has_forbidden = "forbidden_operations" in prof
|
|
if has_allowed != has_forbidden:
|
|
raise ValueError(
|
|
f"Profile '{name}' must define both allowed_operations and "
|
|
"forbidden_operations, or neither (fail closed)"
|
|
)
|
|
if has_allowed:
|
|
allowed = prof.get("allowed_operations")
|
|
forbidden = prof.get("forbidden_operations")
|
|
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
|
raise ValueError(
|
|
f"Profile '{name}' operation fields must be lists"
|
|
)
|
|
try:
|
|
identity_data["allowed_operations"] = canonicalize_operations(
|
|
allowed, context=f"profile '{name}' allowed_operations"
|
|
)
|
|
identity_data["forbidden_operations"] = canonicalize_operations(
|
|
forbidden, context=f"profile '{name}' forbidden_operations"
|
|
)
|
|
except ValueError as exc:
|
|
raise ValueError(f"Profile '{name}': {exc}") from exc
|
|
elif role == "author":
|
|
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
|
|
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
|
|
elif role == "reviewer":
|
|
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
|
|
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
|
|
elif role == "reconciler":
|
|
identity_data["allowed_operations"] = list(RECONCILER_DEFAULT_ALLOWED)
|
|
identity_data["forbidden_operations"] = list(RECONCILER_DEFAULT_FORBIDDEN)
|
|
else:
|
|
raise ValueError(
|
|
f"Profile '{name}' has no explicit operation lists and no "
|
|
"unambiguous author/reviewer role marker (fail closed)"
|
|
)
|
|
|
|
if role == "reconciler":
|
|
_assert_reconciler_required_survive(
|
|
identity_data["allowed_operations"], name
|
|
)
|
|
|
|
# Nest inside environments/services structure
|
|
env = environments.setdefault(env_name, {})
|
|
services = env.setdefault("services", {})
|
|
gitea_svc = services.setdefault("gitea", {})
|
|
|
|
# Copy service-level attributes
|
|
if prof.get("base_url"):
|
|
gitea_svc["base_url"] = prof["base_url"]
|
|
if prof.get("default_owner"):
|
|
gitea_svc["default_owner"] = prof["default_owner"]
|
|
if prof.get("default_repo"):
|
|
gitea_svc["default_repo"] = prof["default_repo"]
|
|
|
|
identities = gitea_svc.setdefault("identities", {})
|
|
identities[ident_name] = identity_data
|
|
|
|
# Alias resolution targets
|
|
alias_target = f"{env_name}.gitea.{ident_name}"
|
|
if name != alias_target:
|
|
aliases[name] = alias_target
|
|
|
|
# Extra convenience alias for standard old-profile compatibility (e.g. prgs-author)
|
|
convenience_alias = f"{env_name}-{ident_name}"
|
|
if convenience_alias != alias_target and convenience_alias not in aliases:
|
|
aliases[convenience_alias] = alias_target
|
|
|
|
v2_data = {
|
|
"version": 2,
|
|
"environments": environments,
|
|
"aliases": aliases
|
|
}
|
|
return v2_data
|
|
|
|
|
|
def validate_v2_data(v2_data):
|
|
"""Validate generated v2 structure using gitea_config parser."""
|
|
fd, temp_path = tempfile.mkstemp(suffix=".json")
|
|
os.close(fd)
|
|
try:
|
|
with open(temp_path, "w") as f:
|
|
json.dump(v2_data, f)
|
|
# Attempt to load using load_config to run all validation rules
|
|
gitea_config.load_config(temp_path)
|
|
return True
|
|
except Exception as e:
|
|
raise ValueError(f"Generated v2 config failed validation: {e}")
|
|
finally:
|
|
try:
|
|
os.remove(temp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Migrate profiles.json from version 1 to version 2 environments shape."
|
|
)
|
|
parser.add_argument(
|
|
"-i", "--input",
|
|
default=gitea_config.DEFAULT_CONFIG_PATH,
|
|
help="Path to the version 1 profiles.json file (default: ~/.config/gitea-tools/profiles.json)"
|
|
)
|
|
parser.add_argument(
|
|
"-o", "--output",
|
|
help="Path to write the migrated version 2 profiles.json file (default: overwrite input)"
|
|
)
|
|
parser.add_argument(
|
|
"-w", "--write",
|
|
action="store_true",
|
|
help="Actually write the migrated config and create a backup (default is dry-run)"
|
|
)
|
|
parser.add_argument(
|
|
"--backup",
|
|
help="Path to write the backup file (default: <input_path>.bak)"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
input_path = os.path.abspath(args.input)
|
|
output_path = os.path.abspath(args.output or input_path)
|
|
backup_path = args.backup or f"{input_path}.bak"
|
|
|
|
if not os.path.isfile(input_path):
|
|
print(f"Error: Input file not found: {input_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
with open(input_path, "r") as f:
|
|
v1_data = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
print(f"Error: Input file is not valid JSON: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error reading input file: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Validate version
|
|
version = v1_data.get("version")
|
|
if version is not None and version != 1:
|
|
print(f"Error: Unsupported profiles.json version: {version}. Expected version 1.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
v2_data = migrate_v1_to_v2(v1_data)
|
|
validate_v2_data(v2_data)
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not args.write:
|
|
print("=== DRY-RUN MODE (No files modified) ===")
|
|
print("Generated v2 config validated successfully.")
|
|
print("Only aggregate counts are shown.")
|
|
summary = migration_summary(v2_data)
|
|
print("Summary:")
|
|
print(f" version: {summary['version']}")
|
|
print(f" environments: {summary['environments']}")
|
|
print(f" services: {summary['services']}")
|
|
print(f" identities: {summary['identities']}")
|
|
print(f" aliases: {summary['aliases']}")
|
|
sys.exit(0)
|
|
|
|
# Write Mode: Create Backup first
|
|
try:
|
|
print(f"Creating backup: {backup_path}")
|
|
shutil.copy2(input_path, backup_path)
|
|
except Exception as e:
|
|
print(f"Error creating backup: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Write migrated config
|
|
try:
|
|
print(f"Writing migrated version 2 config: {output_path}")
|
|
# Ensure target directory exists
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
with open(output_path, "w") as f:
|
|
json.dump(v2_data, f, indent=2)
|
|
f.write("\n")
|
|
print("Migration completed successfully!")
|
|
except Exception as e:
|
|
print(f"Error writing output file: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|