Files
Gitea-Tools/merger_lease_adoption.py
T
sysadmin 08ea214526 feat: guarded merger lease adoption flow (#536)
Replace manual in-process _SESSION_LEASE seeding with an auditable
gitea_adopt_merger_pr_lease tool that posts durable adoption proof and
records sanctioned session provenance. Mutation gates now reject bare
record_session_lease calls without provenance from acquire/adopt/heartbeat.

Closes #536
2026-07-08 11:03:56 -04:00

253 lines
8.9 KiB
Python

"""Guarded merger adoption of an existing reviewer PR lease (#536).
Replaces manual in-process ``_SESSION_LEASE`` seeding with an auditable,
comment-backed adoption path for merger sessions that inherit a reviewer lease
after formal approval at the current PR head.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import reviewer_pr_lease as leases
ADOPTION_MARKER = "<!-- mcp-review-lease-adoption:v1 -->"
DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
SANCTIONED_PROVENANCE_SOURCES = frozenset({
SOURCE_ADOPT,
SOURCE_ACQUIRE,
SOURCE_HEARTBEAT,
})
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
def format_adoption_body(
*,
repo: str,
pr_number: int,
issue_number: int | None,
adopter_identity: str,
adopter_profile: str,
adopter_session_id: str,
worktree: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
adopted_from_session_id: str,
adopted_from_profile: str,
adopted_from_reviewer_identity: str,
adopted_from_comment_id: int | None,
adoption_reason: str = DEFAULT_ADOPTION_REASON,
adopted_at: datetime | None = None,
) -> str:
"""Render a durable merger adoption proof comment."""
adopted_at = adopted_at or datetime.now(timezone.utc)
adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
lease_body = leases.format_lease_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number,
reviewer_identity=adopter_identity,
profile=adopter_profile,
session_id=adopter_session_id,
worktree=worktree,
phase="adopted",
candidate_head=candidate_head,
target_branch=target_branch,
target_branch_sha=target_branch_sha,
last_activity=adopted_at,
blocker="none",
)
lines = [
ADOPTION_MARKER,
f"adopted_at: {adopted_text}",
f"adopted_by_identity: {adopter_identity}",
f"adopted_by_profile: {adopter_profile}",
f"adopted_from_session_id: {adopted_from_session_id}",
f"adopted_from_profile: {adopted_from_profile}",
f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}",
f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}",
f"adoption_reason: {adoption_reason}",
lease_body,
]
return "\n".join(lines)
def is_adoption_comment(body: str) -> bool:
return ADOPTION_MARKER in (body or "")
def build_lease_provenance(
*,
source: str,
comment_id: int | None = None,
adopted_from_session_id: str | None = None,
adopted_from_profile: str | None = None,
adopted_from_reviewer_identity: str | None = None,
adoption_reason: str | None = None,
recorded_at: datetime | None = None,
) -> dict[str, Any]:
recorded_at = recorded_at or datetime.now(timezone.utc)
recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
proof = {
"source": source,
"recorded_at": recorded_text,
}
if comment_id is not None:
proof["comment_id"] = comment_id
if adopted_from_session_id:
proof["adopted_from_session_id"] = adopted_from_session_id
if adopted_from_profile:
proof["adopted_from_profile"] = adopted_from_profile
if adopted_from_reviewer_identity:
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
if adoption_reason:
proof["adoption_reason"] = adoption_reason
return proof
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
if not session:
return False
provenance = session.get("lease_provenance") or {}
source = (provenance.get("source") or "").strip()
if source not in SANCTIONED_PROVENANCE_SOURCES:
return False
if source == SOURCE_ADOPT:
return bool(provenance.get("comment_id")) and bool(
provenance.get("adopted_from_session_id")
)
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
return bool(session.get("comment_id") or provenance.get("comment_id"))
return False
def assess_adopt_merger_lease(
comments: list[dict],
*,
pr_number: int,
adopter_identity: str,
adopter_profile: str,
adopter_session_id: str,
repo: str,
issue_number: int | None,
worktree: str,
expected_head_sha: str | None,
live_head_sha: str | None,
approval_at_head: bool,
pr_open: bool = True,
now: datetime | None = None,
) -> dict[str, Any]:
"""Decide whether a merger may adopt the active reviewer lease (#536)."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
pinned = leases._normalize_sha(expected_head_sha)
live = leases._normalize_sha(live_head_sha)
if not pr_open:
reasons.append(
f"PR #{pr_number} is not open; merger lease adoption is only for open PRs"
)
if not approval_at_head:
reasons.append(
"formal APPROVED review at the current PR head is required before "
"merger lease adoption (fail closed)"
)
if not pinned:
reasons.append("expected_head_sha is required for merger lease adoption")
if not live:
reasons.append("live PR head SHA unavailable (fail closed)")
elif pinned and live and pinned != live:
reasons.append(
"expected_head_sha does not match live PR head; refresh approval before adoption"
)
active = leases.find_active_reviewer_lease(
comments, pr_number=pr_number, now=now
)
if not active:
reasons.append(
f"no active reviewer lease on PR #{pr_number} to adopt; reviewer "
"must acquire first"
)
else:
owner_session = (active.get("session_id") or "").strip()
freshness = active.get("freshness") or leases.classify_lease_freshness(
active, now=now
)
if freshness not in _MERGER_ADOPTABLE_FRESHNESS:
reasons.append(
f"active reviewer lease freshness is '{freshness}'; explicit "
"reclaim is not implemented (fail closed)"
)
lease_head = active.get("candidate_head")
if lease_head and live and lease_head != live:
reasons.append(
"active reviewer lease candidate_head differs from live PR head"
)
if owner_session and owner_session == adopter_session_id:
# Same session already holds the thread lease — allow idempotent adopt
# only when the newest entry is not yet an adoption by this session.
entries = leases._lease_entries(comments, pr_number=pr_number)
newest = entries[-1] if entries else None
if newest and (newest.get("phase") or "") == "adopted":
reasons.append(
"merger session already recorded an adoption lease on this PR"
)
elif owner_session and owner_session != adopter_session_id:
# Cross-session merger handoff: adopt the foreign reviewer lease.
pass
if not (adopter_identity or "").strip():
reasons.append("adopter identity required")
if not (adopter_session_id or "").strip():
reasons.append("adopter session_id required")
if not (worktree or "").strip():
reasons.append("merger worktree path required")
if "merger" not in (adopter_profile or "").lower():
reasons.append(
f"profile '{adopter_profile}' is not a merger profile; adoption is "
"merger-only (fail closed)"
)
adopt_allowed = not reasons
adoption_body = None
if adopt_allowed and active:
adoption_body = format_adoption_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number or active.get("issue_number"),
adopter_identity=adopter_identity,
adopter_profile=adopter_profile,
adopter_session_id=adopter_session_id,
worktree=worktree,
candidate_head=live,
target_branch=active.get("target_branch") or "master",
target_branch_sha=active.get("target_branch_sha"),
adopted_from_session_id=active.get("session_id") or "",
adopted_from_profile=active.get("profile") or "unknown",
adopted_from_reviewer_identity=active.get("reviewer_identity") or "",
adopted_from_comment_id=active.get("comment_id"),
adopted_at=now,
)
return {
"adopt_allowed": adopt_allowed,
"reasons": reasons,
"active_lease": active,
"adoption_body": adoption_body,
"adopter_session_id": adopter_session_id,
"expected_head_sha": pinned,
"live_head_sha": live,
}