Rebase onto current prgs/master and fix cleanup-mutations regex so "Cleanup mutations: none" does not false-positive the proof checklist. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
239 lines
8.3 KiB
Python
239 lines
8.3 KiB
Python
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
|
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
|
|
|
_CLEANUP_SECTION_HINT = re.compile(
|
|
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
|
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
|
re.IGNORECASE,
|
|
)
|
|
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
|
_CLEANUP_BLOCKER_RE = re.compile(
|
|
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
|
re.IGNORECASE,
|
|
)
|
|
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
|
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
|
r"deleted remote branch|delete_branch)",
|
|
re.IGNORECASE,
|
|
)
|
|
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
|
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
|
r"worktree cleanup performed)",
|
|
re.IGNORECASE,
|
|
)
|
|
_DELETE_CAPABILITY_RE = re.compile(
|
|
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
|
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
|
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
|
re.IGNORECASE,
|
|
)
|
|
_DELETE_TASK_RE = re.compile(
|
|
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
|
re.IGNORECASE,
|
|
)
|
|
_MERGE_RESULT_RE = re.compile(
|
|
r"merge result\s*:\s*(?:merged|success|performed)",
|
|
re.IGNORECASE,
|
|
)
|
|
_MERGE_COMMIT_SHA_RE = re.compile(
|
|
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
|
re.IGNORECASE,
|
|
)
|
|
_PR_HEAD_BRANCH_RE = re.compile(
|
|
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
|
re.IGNORECASE,
|
|
)
|
|
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
|
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
|
re.IGNORECASE,
|
|
)
|
|
_OPEN_PR_INVENTORY_RE = re.compile(
|
|
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
|
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
|
r"no other open pr references branch",
|
|
re.IGNORECASE,
|
|
)
|
|
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
|
r"(?:no active (?:heartbeat|claim|lease)|"
|
|
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
|
re.IGNORECASE,
|
|
)
|
|
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
|
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
|
re.IGNORECASE,
|
|
)
|
|
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
|
_CLEAN_TRACKED_RE = re.compile(
|
|
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
|
re.IGNORECASE,
|
|
)
|
|
_CLEAN_UNTRACKED_RE = re.compile(
|
|
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
|
re.IGNORECASE,
|
|
)
|
|
_WORKTREE_LIST_AFTER_RE = re.compile(
|
|
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
|
re.IGNORECASE,
|
|
)
|
|
_WRONG_BRANCH_RE = re.compile(
|
|
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _claims_remote_delete(text: str) -> bool:
|
|
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
|
|
|
|
|
def _claims_worktree_remove(text: str) -> bool:
|
|
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
|
|
|
|
|
def _branch_safety_fields_present(text: str) -> list[str]:
|
|
missing: list[str] = []
|
|
if not _DELETE_CAPABILITY_RE.search(text):
|
|
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
|
if not _DELETE_TASK_RE.search(text):
|
|
missing.append("delete-branch task named (delete_branch or cleanup)")
|
|
if not _MERGE_RESULT_RE.search(text):
|
|
missing.append("merge result: merged")
|
|
if not _MERGE_COMMIT_SHA_RE.search(text):
|
|
missing.append("merge commit SHA")
|
|
if not _PR_HEAD_BRANCH_RE.search(text):
|
|
missing.append("merged PR head branch / deleted branch name")
|
|
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
|
missing.append("branch not protected proof")
|
|
if not _OPEN_PR_INVENTORY_RE.search(text):
|
|
missing.append("open PR inventory proof (no other PR references branch)")
|
|
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
|
missing.append("no active heartbeat/claim/lease proof")
|
|
return missing
|
|
|
|
|
|
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
|
missing: list[str] = []
|
|
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
|
path = match.group(1).strip() if match else ""
|
|
if not path:
|
|
missing.append("session-owned worktree path")
|
|
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
|
missing.append("worktree path under branches/")
|
|
if not _CLEAN_TRACKED_RE.search(text):
|
|
missing.append("pre-removal tracked state: clean")
|
|
if not _CLEAN_UNTRACKED_RE.search(text):
|
|
missing.append("pre-removal untracked state: clean")
|
|
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
|
missing.append("git worktree list after removal")
|
|
return missing
|
|
|
|
|
|
def assess_post_merge_cleanup_proof(
|
|
report_text: str,
|
|
*,
|
|
cleanup_session: dict | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
|
text = report_text or ""
|
|
session = dict(cleanup_session or {})
|
|
reasons: list[str] = []
|
|
|
|
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
|
blocker = (session.get("blocker") or "").strip()
|
|
if not blocker:
|
|
match = _CLEANUP_BLOCKER_RE.search(text)
|
|
blocker = match.group(1).strip() if match else ""
|
|
if blocker.upper() == CLEANUP_SKIPPED:
|
|
blocker = ""
|
|
if not blocker:
|
|
reasons.append(
|
|
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
|
)
|
|
return {
|
|
"block": bool(reasons),
|
|
"proven": not reasons,
|
|
"outcome": CLEANUP_SKIPPED,
|
|
"remote_delete_claimed": False,
|
|
"worktree_remove_claimed": False,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
|
if reasons
|
|
else "proceed"
|
|
),
|
|
}
|
|
|
|
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
|
return {
|
|
"block": False,
|
|
"proven": True,
|
|
"outcome": None,
|
|
"remote_delete_claimed": False,
|
|
"worktree_remove_claimed": False,
|
|
"reasons": [],
|
|
"safe_next_action": "proceed",
|
|
}
|
|
|
|
remote_delete = bool(
|
|
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
|
)
|
|
worktree_remove = bool(
|
|
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
|
)
|
|
|
|
if _WRONG_BRANCH_RE.search(text):
|
|
reasons.append(
|
|
"cleanup report claims deleted branch that is not the merged PR head branch"
|
|
)
|
|
|
|
if remote_delete:
|
|
reasons.extend(
|
|
f"remote branch deletion missing {field}"
|
|
for field in _branch_safety_fields_present(text)
|
|
)
|
|
|
|
if worktree_remove:
|
|
reasons.extend(
|
|
f"worktree removal missing {field}"
|
|
for field in _worktree_cleanup_fields_present(text)
|
|
)
|
|
|
|
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
|
pass
|
|
|
|
if not remote_delete and not worktree_remove:
|
|
cleanup_mutations = re.search(
|
|
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
|
text,
|
|
re.IGNORECASE,
|
|
)
|
|
if cleanup_mutations:
|
|
reasons.append(
|
|
"cleanup mutations reported without post-merge cleanup proof checklist"
|
|
)
|
|
|
|
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
|
if remote_delete or worktree_remove:
|
|
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"block": block,
|
|
"proven": not block,
|
|
"outcome": outcome,
|
|
"remote_delete_claimed": remote_delete,
|
|
"worktree_remove_claimed": worktree_remove,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
|
"checklist before claiming remote delete or worktree removal"
|
|
if reasons
|
|
else "proceed"
|
|
),
|
|
} |