"""Stale / moot #332 review-decision lock detection and cleanup policy (#594). #332 correctly hard-stops a reviewer session after a terminal live review mutation. After #559 those locks are durable on disk, so they can outlive the work they protect: when the referenced PR is already merged or closed, no same-PR merge sequence remains, yet the durable ledger still blocks unrelated new terminal reviews. This module is pure policy (no Gitea I/O). The MCP tool ``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these helpers, and only then clears durable state when cleanup is allowed. Manual deletion of ``~/.cache/gitea-tools/session-state/*`` is never the canonical path. """ from __future__ import annotations from datetime import datetime, timezone from typing import Any # Keep in sync with gitea_mcp_server._TERMINAL_REVIEW_ACTIONS. TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"}) def last_terminal_mutation(lock: dict | None) -> dict | None: """Return the last terminal live mutation on *lock*, or None.""" if not lock: return None terminals = [ m for m in (lock.get("live_mutations") or []) if isinstance(m, dict) and m.get("action") in TERMINAL_REVIEW_ACTIONS ] return terminals[-1] if terminals else None def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]: """Normalize a Gitea PR payload into merge/closed flags.""" pr = pr_live or {} merged = bool(pr.get("merged") or pr.get("merged_at")) state = (pr.get("state") or "").strip().lower() or None closed = state == "closed" return { "pr_state": state, "pr_merged": merged, "pr_merged_or_closed": merged or closed, "merge_commit_sha": pr.get("merge_commit_sha") or pr.get("merged_commit_sha"), } def lock_summary(lock: dict | None) -> dict[str, Any] | None: """LLM-safe summary of a decision lock (no secrets).""" if lock is None: return None last = last_terminal_mutation(lock) mutations = list(lock.get("live_mutations") or []) return { "task": lock.get("task"), "remote": lock.get("remote"), "org": lock.get("org") or lock.get("ready_org"), "repo": lock.get("repo") or lock.get("ready_repo"), "profile_identity": lock.get("profile_identity") or lock.get("session_profile_lock") or lock.get("session_profile"), "session_profile": lock.get("session_profile"), "ready_pr_number": lock.get("ready_pr_number"), "ready_action": lock.get("ready_action"), "live_mutations_count": len(mutations), "last_terminal": ( { "pr_number": last.get("pr_number"), "action": last.get("action"), "review_id": last.get("review_id"), } if last else None ), "correction_authorized": bool(lock.get("correction_authorized")), "updated_at": lock.get("updated_at") or lock.get("recorded_at"), } def assess_stale_review_decision_lock( lock: dict | None, *, pr_live: dict | None = None, pr_lookup_error: str | None = None, active_profile_identity: str | None = None, ) -> dict[str, Any]: """Assess whether a durable review-decision lock is stale/moot (#594). *pr_live* must be the live Gitea payload for the **last terminal mutation's PR**. When no lock / no terminal mutation exists, cleanup is not applicable. When the PR is still open (or lookup fails), cleanup is forbidden and #332 hard-stop remains in force. """ summary = lock_summary(lock) result: dict[str, Any] = { "has_lock": lock is not None, "lock_summary": summary, "last_terminal_pr": None, "last_terminal_action": None, "is_moot": False, "cleanup_allowed": False, "profile_match": True, "pr_state": None, "pr_merged": False, "pr_merged_or_closed": False, "merge_commit_sha": None, "reasons": [], "recovery_tool": "gitea_cleanup_stale_review_decision_lock", } if lock is None: result["reasons"].append("no review decision lock present") return result # Profile identity gate (caller may re-check with env; pure assess still # reports mismatch when active identity is supplied). stored = ( (lock.get("profile_identity") or lock.get("session_profile_lock") or "") .strip() ) active = (active_profile_identity or "").strip() if active and stored and active != stored: result["profile_match"] = False result["reasons"].append( "review decision lock profile identity does not match active " f"profile '{active}' (fail closed, #594)" ) return result last = last_terminal_mutation(lock) if last is None: result["reasons"].append( "review decision lock has no terminal live mutations; nothing to clear" ) return result pr_number = last.get("pr_number") action = last.get("action") result["last_terminal_pr"] = pr_number result["last_terminal_action"] = action if pr_number is None: result["reasons"].append( "last terminal mutation missing pr_number; refuse cleanup (fail closed)" ) return result if pr_lookup_error: result["reasons"].append( f"could not load live state for PR #{pr_number}: {pr_lookup_error} " "(fail closed, #594)" ) return result if pr_live is None: result["reasons"].append( f"live PR state required for terminal PR #{pr_number} before cleanup " "(fail closed, #594)" ) return result live = classify_pr_live_state(pr_live) result.update( { "pr_state": live["pr_state"], "pr_merged": live["pr_merged"], "pr_merged_or_closed": live["pr_merged_or_closed"], "merge_commit_sha": live["merge_commit_sha"], } ) if not live["pr_merged_or_closed"]: result["reasons"].append( f"terminal review mutation on open PR #{pr_number} is still active " f"({action}); #332 hard-stop remains — cleanup forbidden (#594)" ) return result # Merged or closed: lock is moot. Same-PR merge continuation is impossible. result["is_moot"] = True result["cleanup_allowed"] = True result["reasons"].append( f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is " f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)" ) return result def build_cleanup_audit_record( *, assessment: dict[str, Any], actor_username: str | None, profile_name: str | None, applied: bool, ) -> dict[str, Any]: """Structured durable audit payload for a cleanup attempt.""" last = (assessment.get("lock_summary") or {}).get("last_terminal") or {} return { "event": "stale_review_decision_lock_cleanup", "issue_ref": "#594", "applied": bool(applied), "timestamp": datetime.now(timezone.utc).isoformat(), "actor_username": actor_username, "profile_name": profile_name, "last_terminal_pr": assessment.get("last_terminal_pr") or last.get("pr_number"), "last_terminal_action": assessment.get("last_terminal_action") or last.get("action"), "pr_state": assessment.get("pr_state"), "pr_merged": assessment.get("pr_merged"), "pr_merged_or_closed": assessment.get("pr_merged_or_closed"), "merge_commit_sha": assessment.get("merge_commit_sha"), "prior_live_mutations_count": ( (assessment.get("lock_summary") or {}).get("live_mutations_count") ), "prior_profile_identity": ( (assessment.get("lock_summary") or {}).get("profile_identity") ), "cleanup_allowed": assessment.get("cleanup_allowed"), "is_moot": assessment.get("is_moot"), "reasons": list(assessment.get("reasons") or []), } def format_cleanup_audit_comment(audit: dict[str, Any]) -> str: """Markdown body for a durable Gitea audit comment after cleanup.""" status = "APPLIED" if audit.get("applied") else "ASSESSED (not applied)" lines = [ "## Stale #332 review-decision lock cleanup (#594)", "", f"Status: **{status}**", "", f"- actor: `{audit.get('actor_username')}`", f"- profile: `{audit.get('profile_name')}`", f"- timestamp: `{audit.get('timestamp')}`", f"- last terminal: `{audit.get('last_terminal_action')}` on " f"PR #{audit.get('last_terminal_pr')}", f"- PR state: `{audit.get('pr_state')}` " f"(merged={audit.get('pr_merged')})", f"- merge_commit_sha: `{audit.get('merge_commit_sha')}`", f"- prior live_mutations_count: " f"`{audit.get('prior_live_mutations_count')}`", f"- prior profile_identity: `{audit.get('prior_profile_identity')}`", "", "Manual deletion of session-state files is **not** the workflow.", "This path only clears a lock when the referenced PR is merged/closed.", ] return "\n".join(lines)