"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514).""" from __future__ import annotations import re from typing import Any PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) # Evidence / preservation branches must never be removed by cleanup tools # (e.g. chore/issue-681-preserve-review-session-wip). _PRESERVATION_MARKERS = ("preserve", "preservation", "evidence") def is_preservation_or_evidence_branch(branch: str | None) -> bool: """Return True when *branch* is a preservation/evidence ref that must stay.""" if not branch: return False name = str(branch).lower() return any(marker in name for marker in _PRESERVATION_MARKERS) _RAW_BRANCH_DELETE_PATTERNS = ( re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I), re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I), re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I), ) def raw_branch_delete_commands(text: str | None) -> list[str]: """Return raw git branch-delete commands cited in *text*.""" if not text: return [] commands: list[str] = [] for pattern in _RAW_BRANCH_DELETE_PATTERNS: commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text)) return list(dict.fromkeys(commands)) def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]: """Fail closed when a report uses raw git branch deletion as cleanup proof.""" commands = raw_branch_delete_commands(text) reasons = [ ( "raw git branch deletion bypasses MCP branch.delete cleanup gates: " f"{command}" ) for command in commands ] return { "proven": not reasons, "block": bool(reasons), "commands": commands, "reasons": reasons, "safe_next_action": ( "use gitea_cleanup_merged_pr_branch or another approved cleanup " "helper with explicit branch.delete capability" if reasons else "proceed" ), } def assess_merged_pr_branch_cleanup( *, pr_number: int, head_branch: str, merged: bool, remote_branch_exists: bool, open_pr_heads: set[str], head_on_target: bool | None, delete_capability_allowed: bool, confirmation: str | None, protected_branches: frozenset[str] | None = None, ) -> dict[str, Any]: """Assess whether a merged PR source branch can be deleted via MCP.""" protected = protected_branches or PROTECTED_BRANCHES expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}" reasons: list[str] = [] if not merged: reasons.append("PR is not merged") if not remote_branch_exists: reasons.append("remote branch already absent") if not head_branch: reasons.append("PR head branch is missing") if head_branch in protected: reasons.append(f"branch '{head_branch}' is protected") if is_preservation_or_evidence_branch(head_branch): reasons.append( f"branch '{head_branch}' is a preservation/evidence branch and " "cannot be deleted through merged-PR cleanup" ) if head_branch in open_pr_heads: reasons.append("an open PR still references this head branch") if head_on_target is False: reasons.append("PR head is not an ancestor of the target branch") if head_on_target is None: reasons.append("PR head ancestry could not be proven") if not delete_capability_allowed: reasons.append("gitea.branch.delete capability is not allowed") if confirmation != expected_confirmation: reasons.append( "confirmation must equal " f"'{expected_confirmation}' for branch cleanup" ) safe = not reasons return { "pr_number": pr_number, "head_branch": head_branch, "expected_confirmation": expected_confirmation, "remote_branch_exists": remote_branch_exists, "safe_to_delete": safe, "block_reasons": reasons, "recommended_action": "delete_remote_branch" if safe else "keep_remote_branch", } # --------------------------------------------------------------------------- # #687 remediation: post-delete readback + active ownership protection # --------------------------------------------------------------------------- READBACK_NOT_FOUND = "not_found" READBACK_EXISTS = "exists" READBACK_AUTHENTICATION = "authentication_error" READBACK_AUTHORIZATION = "authorization_error" READBACK_TRANSPORT = "transport_error" READBACK_UNEXPECTED = "unexpected_response" READBACK_AMBIGUOUS_404 = "ambiguous_not_found" # Scope of a 404: only branch-scoped absence may set verified_absent. NOT_FOUND_SCOPE_BRANCH = "branch" NOT_FOUND_SCOPE_REPOSITORY = "repository" NOT_FOUND_SCOPE_HOST = "host" NOT_FOUND_SCOPE_UNKNOWN = "unknown" ERROR_CLASS_AUTHENTICATION = "authentication" ERROR_CLASS_AUTHORIZATION = "authorization" ERROR_CLASS_TRANSPORT = "transport" ERROR_CLASS_UNEXPECTED = "unexpected" OWNERSHIP_CATEGORY_AUTHOR_SESSION = "author_session" OWNERSHIP_CATEGORY_AUTHOR_LEASE = "author_lease" OWNERSHIP_CATEGORY_REVIEWER_LEASE = "reviewer_lease" OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease" OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease" OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease" OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding" OWNERSHIP_CATEGORY_INVENTORY_ERROR = "ownership_inventory_error" _ROLE_TO_OWNERSHIP_CATEGORY = { "author": OWNERSHIP_CATEGORY_AUTHOR_LEASE, "reviewer": OWNERSHIP_CATEGORY_REVIEWER_LEASE, "merger": OWNERSHIP_CATEGORY_MERGER_LEASE, "controller": OWNERSHIP_CATEGORY_CONTROLLER_LEASE, "reconciler": OWNERSHIP_CATEGORY_RECONCILER_LEASE, } _ACTIVE_OWNERSHIP_STATUSES = frozenset( {"active", "live", "claimed", "in_progress", "working", "pushing", "pushed"} ) _TERMINAL_OWNERSHIP_STATUSES = frozenset( {"released", "abandoned", "done", "blocked", "terminal", "closed"} ) _EXPIRED_STATUSES = frozenset({"expired"}) _STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"}) def _norm_str(value: Any) -> str: return str(value or "").strip() def normalize_host(host: str | None) -> str: """Normalize a host identity for ownership matching (no credentials).""" text = _norm_str(host).lower() for prefix in ("https://", "http://"): if text.startswith(prefix): text = text[len(prefix) :] # Drop path/query if a full URL slipped through. text = text.split("/", 1)[0] text = text.split("?", 1)[0] return text.rstrip(".") def ownership_category_for_role(role: str | None) -> str: """Map a role kind to a non-secret ownership category label.""" key = _norm_str(role).lower() return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease") def classify_branch_readback_http_status( status_code: int | None, *, not_found_scope: str | None = None, ) -> dict[str, Any]: """Classify a GET-branch HTTP status into a secret-free readback result. R1: A bare/generic/repository/wrong-host 404 never yields ``verified_absent=True``. Only an authoritative *branch-scoped* not-found (``not_found_scope='branch'``) may verify deletion. """ if status_code == 404: scope = _norm_str(not_found_scope).lower() or NOT_FOUND_SCOPE_UNKNOWN if scope == NOT_FOUND_SCOPE_BRANCH: return { "status": READBACK_NOT_FOUND, "error_class": None, "verified_absent": True, "branch_present": False, "not_found_scope": NOT_FOUND_SCOPE_BRANCH, "reasons": [], } # repository / host / unknown / generic 404 — not verified absence reason = { NOT_FOUND_SCOPE_REPOSITORY: ( "post-delete readback 404 is repository-scoped, not branch absence" ), NOT_FOUND_SCOPE_HOST: ( "post-delete readback 404 is host-scoped, not branch absence" ), }.get( scope, "post-delete readback 404 is ambiguous (not branch-scoped); " "cannot verify absence", ) return { "status": READBACK_AMBIGUOUS_404, "error_class": ERROR_CLASS_UNEXPECTED, "verified_absent": False, "branch_present": None, "not_found_scope": scope, "reasons": [reason], } if status_code in (401, 407): return { "status": READBACK_AUTHENTICATION, "error_class": ERROR_CLASS_AUTHENTICATION, "verified_absent": False, "branch_present": None, "reasons": ["post-delete branch readback authentication failed"], } if status_code == 403: return { "status": READBACK_AUTHORIZATION, "error_class": ERROR_CLASS_AUTHORIZATION, "verified_absent": False, "branch_present": None, "reasons": ["post-delete branch readback authorization failed"], } if status_code is not None and 200 <= int(status_code) < 300: return { "status": READBACK_EXISTS, "error_class": None, "verified_absent": False, "branch_present": True, "reasons": ["post-delete readback found branch still present"], } if status_code is not None and int(status_code) >= 500: return { "status": READBACK_TRANSPORT, "error_class": ERROR_CLASS_TRANSPORT, "verified_absent": False, "branch_present": None, "reasons": ["post-delete branch readback transport/upstream failure"], } return { "status": READBACK_UNEXPECTED, "error_class": ERROR_CLASS_UNEXPECTED, "verified_absent": False, "branch_present": None, "reasons": ["post-delete branch readback returned an unexpected response"], "http_status": status_code, } def _extract_http_status(exc: BaseException) -> int | None: """Extract an HTTP status code from an exception chain (secret-free).""" seen: set[int] = set() current: BaseException | None = exc while current is not None and id(current) not in seen: seen.add(id(current)) for attr in ("code", "status", "status_code"): value = getattr(current, attr, None) if isinstance(value, int) and 100 <= value <= 599: return value text = str(current) if current is not None else "" match = re.match(r"HTTP\s+(\d{3})\b", text) if match: return int(match.group(1)) current = current.__cause__ or current.__context__ return None def classify_branch_readback_exception( exc: BaseException, *, not_found_scope: str | None = None, ) -> dict[str, Any]: """Classify a GET-branch exception without leaking credentials or bodies. R1: substring ``404`` / ``not found`` alone never becomes verified_absent. Callers must pass ``not_found_scope='branch'`` only after authoritative proof that the repository/host is still reachable and the 404 is branch-level. """ status_code = _extract_http_status(exc) if status_code is None: lower = str(exc).lower() if exc is not None else "" if any( token in lower for token in ( "timed out", "timeout", "connection", "network", "temporarily unavailable", "name or service not known", "nodename nor servname", ) ): return { "status": READBACK_TRANSPORT, "error_class": ERROR_CLASS_TRANSPORT, "verified_absent": False, "branch_present": None, "reasons": [ "post-delete branch readback transport/upstream failure" ], } if "unauthorized" in lower: status_code = 401 elif "forbidden" in lower: status_code = 403 elif "404" in lower or "not found" in lower: # Ambiguous: do NOT treat as branch absence without scope proof. status_code = 404 if not_found_scope is None: not_found_scope = NOT_FOUND_SCOPE_UNKNOWN if status_code is not None: return classify_branch_readback_http_status( status_code, not_found_scope=not_found_scope ) return { "status": READBACK_UNEXPECTED, "error_class": ERROR_CLASS_UNEXPECTED, "verified_absent": False, "branch_present": None, "reasons": ["post-delete branch readback returned an unexpected response"], } def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]: """Decide whether DELETE success may be reported after branch readback. Success requires authoritative branch-scoped not-found (``verified_absent=True``). DELETE HTTP success alone is never enough. Generic/repository/host 404 cannot verify absence (R1). """ payload = dict(readback or {}) status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED # Only explicit verified_absent flag counts — never infer from status alone # when status is a bare not_found without branch scope proof. verified = bool(payload.get("verified_absent")) is True if verified and status == READBACK_NOT_FOUND: return { "ok": True, "success": True, "verified_absent": True, "readback": { "status": READBACK_NOT_FOUND, "verified_absent": True, "branch_present": False, "error_class": None, "not_found_scope": NOT_FOUND_SCOPE_BRANCH, }, "reasons": [], } reasons = list(payload.get("reasons") or []) if not reasons: if status == READBACK_EXISTS: reasons = ["post-delete readback found branch still present"] elif status == READBACK_AUTHENTICATION: reasons = ["post-delete branch readback authentication failed"] elif status == READBACK_AUTHORIZATION: reasons = ["post-delete branch readback authorization failed"] elif status == READBACK_TRANSPORT: reasons = ["post-delete branch readback transport/upstream failure"] elif status == READBACK_AMBIGUOUS_404: reasons = [ "post-delete readback 404 is not branch-scoped; " "cannot verify absence" ] else: reasons = ["post-delete branch readback could not verify deletion"] return { "ok": False, "success": False, "verified_absent": False, "readback": { "status": status, "verified_absent": False, "branch_present": payload.get("branch_present"), "error_class": payload.get("error_class"), "not_found_scope": payload.get("not_found_scope"), }, "reasons": reasons, "blocker_kind": "post_delete_readback_failed", } def cleanup_result_envelope( *, success: bool, performed: bool, delete_acknowledged: bool, verified_absent: bool, **extra: Any, ) -> dict[str, Any]: """R2: consistent top-level cleanup result fields on every return path.""" out: dict[str, Any] = { "success": bool(success), "performed": bool(performed), "delete_acknowledged": bool(delete_acknowledged), "verified_absent": bool(verified_absent), } out.update(extra) return out def _repo_matches( record: dict[str, Any], *, remote: str, org: str, repo: str, host: str | None = None, ) -> bool: """Match ownership record to target remote/org/repo and normalized host.""" if ( _norm_str(record.get("remote")).lower() != _norm_str(remote).lower() or _norm_str(record.get("org")).lower() != _norm_str(org).lower() or _norm_str(record.get("repo")).lower() != _norm_str(repo).lower() ): return False expected_host = normalize_host(host) record_host = normalize_host(record.get("host") or record.get("host_name")) # When both sides declare a host, they must agree after normalization. # A record host that disagrees with the expected host is out of scope. # Legacy records without host still match when remote/org/repo agree. if expected_host and record_host and expected_host != record_host: return False return True def _branch_matches(record: dict[str, Any], branch: str) -> bool: return _norm_str(record.get("branch")) == _norm_str(branch) def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]: """Classify one ownership record as blocking or non-blocking. Distinguishes active ownership from expired/released/terminal/stale records. Expired/stale records block unless reclaim_allowed is *explicitly* True (O2: never treat missing/unknown reclaim as auto-allowed). """ status = _norm_str(record.get("status")).lower() category = _norm_str(record.get("category")) or "unknown" reclaim_allowed = record.get("reclaim_allowed") if category == OWNERSHIP_CATEGORY_INVENTORY_ERROR: return { "blocks": True, "status": status or "unknown", "category": category, "reason": "ownership inventory failed closed", } if status in _TERMINAL_OWNERSHIP_STATUSES: return { "blocks": False, "status": status, "category": category, "reason": f"{category} ownership is terminal/released ({status})", } if status in _ACTIVE_OWNERSHIP_STATUSES: return { "blocks": True, "status": status, "category": category, "reason": f"active {category} ownership still uses the target branch", } if status in _EXPIRED_STATUSES or status in _STALE_STATUSES: # O2: only explicit reclaim_allowed=True skips the block. if reclaim_allowed is True: return { "blocks": False, "status": status, "category": category, "reason": ( f"{category} ownership is {status} and reclaimable; " "does not block deletion" ), } return { "blocks": True, "status": status, "category": category, "reason": ( f"{status} {category} ownership still protects the target " "branch (reclaim not proven; fail closed)" ), } # Unknown status → fail closed return { "blocks": True, "status": status or "unknown", "category": category, "reason": ( f"unclassified {category} ownership status " f"'{status or 'unknown'}'; fail closed" ), } def assess_active_branch_ownership( *, remote: str, org: str, repo: str, branch: str, host: str | None = None, records: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Assess whether any active ownership still uses *branch* in *repo*. Matching requires remote/org/repo/branch and, when provided, normalized host identity. Other repositories, hosts, or branches never false-block. """ target_branch = _norm_str(branch) expected_host = normalize_host(host) considered: list[dict[str, Any]] = [] blocking: list[dict[str, Any]] = [] ignored: list[dict[str, Any]] = [] for raw in records or []: if not isinstance(raw, dict): continue if not _repo_matches( raw, remote=remote, org=org, repo=repo, host=expected_host or None ): ignored.append( { "category": _norm_str(raw.get("category")) or "unknown", "reason": "different repository or host scope", } ) continue if not _branch_matches(raw, target_branch): ignored.append( { "category": _norm_str(raw.get("category")) or "unknown", "reason": "different branch", } ) continue activity = assess_ownership_record_activity(raw) entry = { "category": activity["category"], "status": activity["status"], "blocks": activity["blocks"], "reason": activity["reason"], } considered.append(entry) if activity["blocks"]: blocking.append(entry) block = bool(blocking) categories = sorted({b["category"] for b in blocking}) reasons = [ ( "active ownership protects branch " f"'{target_branch}': " + "; ".join(b["reason"] for b in blocking) ) ] if block else [] return { "block": block, "safe_to_delete": not block, "remote": remote, "org": org, "repo": repo, "host": expected_host or None, "branch": target_branch, "blocking_categories": categories, "blocking": blocking, "considered": considered, "ignored_out_of_scope": ignored, "reasons": reasons, "blocker_kind": "active_branch_ownership" if block else None, "recommended_action": "keep_remote_branch" if block else "delete_remote_branch", }