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
253 lines
8.9 KiB
Python
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,
|
|
} |