"""Reconciler profile model for already-landed PR closure (#304). Defines the narrowly scoped operation set for a dedicated reconciler profile such as ``prgs-reconciler``. Close MCP tooling and ancestry gates ship in the #310 stack; this module validates profile shape only. """ from __future__ import annotations import gitea_config RECONCILER_REQUIRED_OPERATIONS = ( "gitea.read", "gitea.pr.close", ) RECONCILER_RECOMMENDED_OPERATIONS = ( "gitea.pr.comment", "gitea.issue.comment", "gitea.issue.close", ) RECONCILER_FORBIDDEN_OPERATIONS = ( "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review", "gitea.pr.create", "gitea.branch.push", "gitea.repo.commit", ) def _normalized_allowed(allowed: list[str]) -> set[str]: normalized: set[str] = set() for entry in allowed or []: try: normalized.add(gitea_config.normalize_operation(entry)) except gitea_config.ConfigError: continue return normalized def _forbidden_in_allowed(allowed: list[str], ops: tuple[str, ...]) -> list[str]: """Return forbidden ops that are explicitly listed in *allowed*.""" allowed_n = _normalized_allowed(allowed) present: list[str] = [] for op in ops: try: if gitea_config.normalize_operation(op) in allowed_n: present.append(op) except gitea_config.ConfigError: continue return present def is_reconciler_profile(allowed: list[str], forbidden: list[str]) -> bool: """Return True when *allowed*/*forbidden* describe a reconciler profile.""" def can(op: str) -> bool: return gitea_config.check_operation(op, allowed, forbidden)[0] if not can("gitea.pr.close"): return False if _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS): return False return True def assess_reconciler_profile(allowed: list[str], forbidden: list[str]) -> dict: """Validate reconciler profile operations (read-only, fail closed).""" allowed = list(allowed or []) forbidden = list(forbidden or []) reasons: list[str] = [] for op in RECONCILER_REQUIRED_OPERATIONS: ok, _ = gitea_config.check_operation(op, allowed, forbidden) if not ok: reasons.append(f"missing required operation {op}") for op in _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS): reasons.append(f"forbidden operation must not be allowed: {op}") missing_recommended = [ op for op in RECONCILER_RECOMMENDED_OPERATIONS if not gitea_config.check_operation(op, allowed, forbidden)[0] ] return { "is_reconciler_profile": is_reconciler_profile(allowed, forbidden), "valid": not reasons and is_reconciler_profile(allowed, forbidden), "reasons": reasons, "missing_recommended_operations": missing_recommended, "required_operations": list(RECONCILER_REQUIRED_OPERATIONS), "forbidden_operations": list(RECONCILER_FORBIDDEN_OPERATIONS), }