Durable review-decision locks (#559) can outlive the PR they protect. When the last terminal mutation references a PR that is already merged/closed, expose gitea_cleanup_stale_review_decision_lock so a reviewer can clear the lock with identity/profile gates and a durable audit trail — without weakening #332 for open PRs or deleting session-state files by hand. Also auto-clear same-profile locks after a successful merge of the approved PR, and document allowed vs forbidden cleanup. Closes #594
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user