"""Conflict-fix and reviewer PR work leases (#399, #407 reader). Structured PR/issue comments prove exclusive phases so author conflict-fix pushes cannot race reviewer validation/approval/merge on the same head. """ from __future__ import annotations import re from datetime import datetime, timedelta, timezone from typing import Any REVIEWER_LEASE_MARKER = "" CONFLICT_FIX_LEASE_MARKER = "" _FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) _FIELD_RE = re.compile( r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE, ) _TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"}) _ACTIVE_REVIEWER_PHASES = frozenset({ "claimed", "validating", "approved", "request-changes", "merging", }) _TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"}) _ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"}) DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120 DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120 def _parse_timestamp(value: str | None) -> datetime | None: if not value: return None text = value.strip() if text.endswith("Z"): text = text[:-1] + "+00:00" try: parsed = datetime.fromisoformat(text) except ValueError: return None if parsed.tzinfo is None: return parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc) def _normalize_sha(value: str | None) -> str | None: text = (value or "").strip().lower() if not text: return None return text if _FULL_SHA.match(text) else None def _parse_pr_ref(value: str | None) -> int | None: digits = re.sub(r"[^\d]", "", value or "") return int(digits) if digits.isdigit() else None def _parse_marker_comment(body: str, marker: str) -> dict[str, str] | None: text = body or "" if marker not in text: return None fields: dict[str, str] = {} for match in _FIELD_RE.finditer(text): fields[match.group(1).strip().lower()] = match.group(2).strip() return fields or None def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None: fields = _parse_marker_comment(body, REVIEWER_LEASE_MARKER) if not fields: return None return { "lease_kind": "reviewer", "pr_number": _parse_pr_ref(fields.get("pr")), "issue_number": _parse_pr_ref(fields.get("issue")), "reviewer_identity": fields.get("reviewer_identity"), "profile": fields.get("profile"), "session_id": fields.get("session_id"), "worktree": fields.get("worktree"), "phase": (fields.get("phase") or "").strip().lower() or None, "candidate_head": _normalize_sha(fields.get("candidate_head")), "target_branch": fields.get("target_branch"), "target_branch_sha": _normalize_sha(fields.get("target_branch_sha")), "last_activity": fields.get("last_activity"), "expires_at": fields.get("expires_at"), "blocker": fields.get("blocker"), "raw_fields": fields, } def parse_conflict_fix_lease_comment(body: str) -> dict[str, Any] | None: fields = _parse_marker_comment(body, CONFLICT_FIX_LEASE_MARKER) if not fields: return None ff = (fields.get("fast_forward") or "").strip().lower() reviewer_active = (fields.get("reviewer_active") or "").strip().lower() return { "lease_kind": "conflict_fix", "pr_number": _parse_pr_ref(fields.get("pr")), "branch": fields.get("branch"), "worktree": fields.get("worktree"), "profile": fields.get("profile"), "session_id": fields.get("session_id"), "phase": (fields.get("phase") or "").strip().lower() or None, "head_before": _normalize_sha(fields.get("head_before")), "head_after": _normalize_sha(fields.get("head_after")), "expires_at": fields.get("expires_at"), "reviewer_active": reviewer_active in {"yes", "true", "1"}, "fast_forward": ff in {"yes", "true", "1"}, "raw_fields": fields, } def _comment_entries(comments: list[dict], *, pr_number: int | None) -> list[dict]: entries: list[dict] = [] for comment in comments or []: body = comment.get("body") or "" for parser in (parse_reviewer_lease_comment, parse_conflict_fix_lease_comment): parsed = parser(body) if not parsed: continue if pr_number is not None and parsed.get("pr_number") not in (None, pr_number): continue entries.append({ **parsed, "comment_id": comment.get("id"), "author": (comment.get("user") or {}).get("login") or comment.get("author"), "created_at": comment.get("created_at"), "updated_at": comment.get("updated_at"), }) break return entries def _lease_expired(lease: dict, *, now: datetime) -> bool: expires_at = _parse_timestamp(lease.get("expires_at")) return bool(expires_at and expires_at <= now) def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool: phase = (lease.get("phase") or "").strip().lower() if phase in _TERMINAL_REVIEWER_PHASES or phase in _TERMINAL_CONFLICT_FIX_PHASES: return False return phase in active_phases or bool(phase and phase not in ( _TERMINAL_REVIEWER_PHASES | _TERMINAL_CONFLICT_FIX_PHASES )) def find_active_reviewer_lease( comments: list[dict], *, pr_number: int, now: datetime | None = None, ) -> dict[str, Any] | None: """Return the newest unexpired reviewer lease for *pr_number*, if any.""" now = now or datetime.now(timezone.utc) candidates = [ entry for entry in _comment_entries(comments, pr_number=pr_number) if entry.get("lease_kind") == "reviewer" ] for lease in reversed(candidates): if _lease_expired(lease, now=now): continue phase = (lease.get("phase") or "").strip().lower() if phase in _TERMINAL_REVIEWER_PHASES: continue if phase in _ACTIVE_REVIEWER_PHASES or phase: return lease return None def find_active_conflict_fix_lease( comments: list[dict], *, pr_number: int, now: datetime | None = None, ) -> dict[str, Any] | None: """Return the newest unexpired conflict-fix lease for *pr_number*, if any.""" now = now or datetime.now(timezone.utc) candidates = [ entry for entry in _comment_entries(comments, pr_number=pr_number) if entry.get("lease_kind") == "conflict_fix" ] for lease in reversed(candidates): if _lease_expired(lease, now=now): continue phase = (lease.get("phase") or "").strip().lower() if phase in _TERMINAL_CONFLICT_FIX_PHASES: continue if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase: return lease return None def format_conflict_fix_lease_body( *, pr_number: int, branch: str, worktree: str, profile: str, head_before: str, phase: str = "claimed", session_id: str = "unknown", expires_at: datetime | None = None, reviewer_active: bool = False, ) -> str: expires = expires_at or ( datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_CONFLICT_FIX_TTL_MINUTES) ) expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( "+00:00", "Z" ) lines = [ CONFLICT_FIX_LEASE_MARKER, f"pr: #{pr_number}", f"branch: {branch}", f"worktree: {worktree}", f"profile: {profile}", f"session_id: {session_id}", f"phase: {phase}", f"head_before: {head_before}", f"expires_at: {expires_text}", f"reviewer_active: {'yes' if reviewer_active else 'no'}", ] return "\n".join(lines) def assess_head_sha_equality( reviewed_head_sha: str | None, live_head_sha: str | None, ) -> dict[str, Any]: """Fail closed when reviewed and live PR heads differ.""" reviewed = _normalize_sha(reviewed_head_sha) live = _normalize_sha(live_head_sha) reasons: list[str] = [] if not reviewed or not live: reasons.append( "reviewed/live head SHA missing or not full 40-hex; fail closed" ) elif reviewed != live: reasons.append( "PR head changed after validation; re-pin and re-validate before " "approval or merge" ) proven = not reasons return { "proven": proven, "block": not proven, "reasons": reasons, "reviewed_head_sha": reviewed, "live_head_sha": live, "head_changed": bool(reviewed and live and reviewed != live), } def assess_conflict_fix_push( *, pr_number: int, comments: list[dict], branch_head_before: str | None, branch_head_after: str | None, worktree_path: str | None, push_cwd: str | None, is_fast_forward: bool | None, now: datetime | None = None, ) -> dict[str, Any]: """Author pre-push gate: block when a reviewer holds an active lease.""" now = now or datetime.now(timezone.utc) reasons: list[str] = [] reviewer_lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now) conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now) if reviewer_lease: reasons.append( f"active reviewer lease on PR #{pr_number} " f"(phase={reviewer_lease.get('phase')}); author push blocked" ) head_before = _normalize_sha(branch_head_before) head_after = _normalize_sha(branch_head_after) if not head_before: reasons.append("branch head before push missing or invalid SHA") if head_after and head_before and head_before == head_after: reasons.append("branch head unchanged; no push to perform") worktree = (worktree_path or "").strip() cwd = (push_cwd or "").strip() if not worktree: reasons.append("worktree path required for conflict-fix push proof") elif cwd and worktree and not cwd.rstrip("/").endswith(worktree.rstrip("/").split("/")[-1]): if worktree not in cwd: reasons.append( f"push cwd '{cwd}' does not match session worktree '{worktree}'" ) if is_fast_forward is False: reasons.append("non-fast-forward push rejected for conflict-fix (fail closed)") if conflict_lease and conflict_lease.get("phase") == "pushing": owner = conflict_lease.get("worktree") if owner and worktree and owner != worktree: reasons.append( f"sibling conflict-fix lease active from worktree '{owner}'" ) push_allowed = not reasons return { "push_allowed": push_allowed, "block": not push_allowed, "reasons": reasons, "active_reviewer_lease": reviewer_lease, "active_conflict_fix_lease": conflict_lease, "branch_head_before": head_before, "branch_head_after": head_after, "reviewer_was_active": bool(reviewer_lease), "fast_forward": is_fast_forward, } def assess_reviewer_mutation_blocked( *, pr_number: int, comments: list[dict], reviewed_head_sha: str | None, live_head_sha: str | None, mutation: str, now: datetime | None = None, ) -> dict[str, Any]: """Reviewer gate: block when conflict-fix lease active or head moved.""" now = now or datetime.now(timezone.utc) reasons: list[str] = [] conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now) if conflict_lease and (conflict_lease.get("phase") or "") in _ACTIVE_CONFLICT_FIX_PHASES: reasons.append( f"active conflict-fix lease on PR #{pr_number} " f"(phase={conflict_lease.get('phase')}); reviewer {mutation} blocked" ) head_check = assess_head_sha_equality(reviewed_head_sha, live_head_sha) if head_check["block"]: reasons.extend(head_check["reasons"]) if not _normalize_sha(reviewed_head_sha): reasons.append( f"reviewed head SHA required before reviewer {mutation} (fail closed)" ) allowed = not reasons return { "mutation_allowed": allowed, "block": not allowed, "reasons": reasons, "active_conflict_fix_lease": conflict_lease, "head_check": head_check, "reviewed_head_sha": head_check.get("reviewed_head_sha"), "live_head_sha": head_check.get("live_head_sha"), "push_during_validation": bool( conflict_lease and conflict_lease.get("phase") in {"pushing", "pushed"} ), } _REVIEWED_HEAD_RE = re.compile( r"reviewed head sha\s*:\s*([0-9a-f]{40})", re.IGNORECASE, ) _LIVE_HEAD_BEFORE_APPROVAL_RE = re.compile( r"(?:live head sha before approval|final live head sha before approval)\s*:\s*([0-9a-f]{40})", re.IGNORECASE, ) _LIVE_HEAD_BEFORE_MERGE_RE = re.compile( r"(?:live head sha before merge|final live head sha before merge)\s*:\s*([0-9a-f]{40})", re.IGNORECASE, ) _PUSH_DURING_VALIDATION_RE = re.compile( r"push(?:es)? occurred during validation\s*:\s*(yes|no|true|false)", re.IGNORECASE, ) _CONFLICT_HEAD_BEFORE_RE = re.compile( r"branch head before push\s*:\s*([0-9a-f]{40})", re.IGNORECASE, ) _CONFLICT_HEAD_AFTER_RE = re.compile( r"branch head after push\s*:\s*([0-9a-f]{40})", re.IGNORECASE, ) _REVIEWER_LEASE_STATUS_RE = re.compile( r"active reviewer lease status\s*:\s*(.+)$", re.IGNORECASE | re.MULTILINE, ) _FAST_FORWARD_RE = re.compile( r"whether push was fast-forward\s*:\s*(yes|no|true|false)", re.IGNORECASE, ) _REVIEWER_ACTIVE_RE = re.compile( r"whether any reviewer was active\s*:\s*(yes|no|true|false)", re.IGNORECASE, ) # #698 phase detection for phase-specific head proofs. _NO_REVIEWED_HEAD_RE = re.compile( r"(?:reviewed head sha|candidate head sha)\s*:\s*none\b", re.IGNORECASE, ) _VERDICT_RECORDED_RE = re.compile( r"review decision\s*:\s*(?:approve[d]?|request[_ ]changes)\b" r"|review_status\s*:\s*(?:approved|request_changes)\b" r"|terminal review mutation\s*:\s*(?!none\b)\S", re.IGNORECASE, ) _MERGE_ATTEMPTED_RE = re.compile( r"merge result\s*:\s*(?:merged|success|performed|failed|attempted)\b" r"|merge mutations\s*:\s*(?!none\b|not applicable\b)\S", re.IGNORECASE, ) _VALIDATION_STARTED_RE = re.compile( r"validation\s*:\s*(?!none\b|not run\b|not applicable\b|not started\b)" r"[^\n]*(?:pass|fail|ran|executed|\d+\s+passed)", re.IGNORECASE, ) def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]: """Final-report proof for reviewed vs live head SHAs (#399 AC 6). #698: head proofs are phase-specific. A legitimately blocked run that never began validation (no reviewed head, no formal verdict, no merge) owes none of them; approval-time and merge-time live-head proofs are owed only once the corresponding phase actually begins. """ text = report_text or "" reasons: list[str] = [] reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None) live_approval = _normalize_sha( _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text).group(1) if _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text) else None ) live_merge = _normalize_sha( _LIVE_HEAD_BEFORE_MERGE_RE.search(text).group(1) if _LIVE_HEAD_BEFORE_MERGE_RE.search(text) else None ) push_during = _PUSH_DURING_VALIDATION_RE.search(text) # Phase detection from the report's own claims. no_head_stated = bool(_NO_REVIEWED_HEAD_RE.search(text)) verdict_recorded = bool(_VERDICT_RECORDED_RE.search(text)) merge_attempted = bool(_MERGE_ATTEMPTED_RE.search(text)) validation_started = bool(reviewed) or bool(_VALIDATION_STARTED_RE.search(text)) blocked_before_validation = ( no_head_stated and not reviewed and not verdict_recorded and not merge_attempted and not validation_started ) if blocked_before_validation: return { "proven": True, "block": False, "reasons": [], "reviewed_head_sha": None, "live_head_sha_before_approval": None, "live_head_sha_before_merge": None, "push_during_validation": ( push_during.group(1).lower() if push_during else None ), "phase": "blocked_before_validation", } if not reviewed and not no_head_stated: # The head must always be STATED — either a SHA or an explicit # 'none'. Silence is not a phase claim and fails closed. reasons.append( "reviewed head SHA not stated in final report " "(state the SHA or an explicit 'none')" ) elif not reviewed and (validation_started or verdict_recorded or merge_attempted): reasons.append("reviewed head SHA not stated in final report") if verdict_recorded and not live_approval: reasons.append("final live head SHA before approval not stated") if merge_attempted and not live_merge: reasons.append("final live head SHA before merge not stated") if validation_started and not push_during: reasons.append("whether push occurred during validation not stated") if reviewed and live_approval and reviewed != live_approval: reasons.append("live head before approval differs from reviewed head SHA") elif reviewed and live_merge and reviewed != live_merge: reasons.append("live head before merge differs from reviewed head SHA") proven = not reasons return { "proven": proven, "block": not proven, "reasons": reasons, "reviewed_head_sha": reviewed, "live_head_sha_before_approval": live_approval, "live_head_sha_before_merge": live_merge, "push_during_validation": (push_during.group(1).lower() if push_during else None), } def assess_conflict_fix_final_report(report_text: str) -> dict[str, Any]: """Final-report proof for conflict-fix push sessions (#399 AC 7).""" text = report_text or "" reasons: list[str] = [] head_before = _normalize_sha( _CONFLICT_HEAD_BEFORE_RE.search(text).group(1) if _CONFLICT_HEAD_BEFORE_RE.search(text) else None ) head_after = _normalize_sha( _CONFLICT_HEAD_AFTER_RE.search(text).group(1) if _CONFLICT_HEAD_AFTER_RE.search(text) else None ) if not head_before: reasons.append("branch head before push not stated") if not head_after: reasons.append("branch head after push not stated") if not _REVIEWER_LEASE_STATUS_RE.search(text): reasons.append("active reviewer lease status not stated") if not _FAST_FORWARD_RE.search(text): reasons.append("whether push was fast-forward not stated") if not _REVIEWER_ACTIVE_RE.search(text): reasons.append("whether any reviewer was active not stated") proven = not reasons return { "proven": proven, "block": not proven, "reasons": reasons, "branch_head_before": head_before, "branch_head_after": head_after, }