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
This commit is contained in:
2026-07-08 11:03:56 -04:00
parent 6b45212659
commit 08ea214526
10 changed files with 905 additions and 10 deletions
+188 -2
View File
@@ -727,6 +727,7 @@ import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
import reviewer_pr_lease # noqa: E402
import merger_lease_adoption # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
@@ -5350,7 +5351,10 @@ def gitea_acquire_reviewer_pr_lease(
"target_branch_sha": target_branch_sha,
"repo": repo_label,
"comment_id": posted.get("id"),
})
}, lease_provenance=merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_ACQUIRE,
comment_id=posted.get("id"),
))
return {
"success": True,
"acquired": True,
@@ -5362,6 +5366,176 @@ def gitea_acquire_reviewer_pr_lease(
}
@mcp.tool()
def gitea_adopt_merger_pr_lease(
pr_number: int,
worktree: str,
expected_head_sha: str,
issue_number: int | None = None,
session_id: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Adopt an active reviewer PR lease for a merger session (#536).
Merger sessions must not manually seed ``reviewer_pr_lease._SESSION_LEASE``.
This tool posts durable adoption proof on the PR thread and records
sanctioned in-session lease provenance so ``gitea_merge_pr`` can proceed
after a separate reviewer session held the lease.
Args:
pr_number: Open PR to adopt.
worktree: Merger workspace under ``branches/``.
expected_head_sha: Approved head the merger will pin during merge.
issue_number: Optional linked issue for the adoption record.
session_id: Optional merger session id (generated when omitted).
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with adoption proof, session lease, and block reasons when the
guarded preconditions are not met.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"adopted": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
comment_block = _profile_operation_gate("gitea.pr.comment")
if comment_block:
return {
"success": False,
"adopted": False,
"reasons": comment_block,
"permission_report": _permission_block_report("gitea.pr.comment"),
}
merge_block = _profile_operation_gate("gitea.pr.merge")
if merge_block:
return {
"success": False,
"adopted": False,
"reasons": merge_block,
"permission_report": _permission_block_report("gitea.pr.merge"),
}
_verify_role_mutation_workspace(
remote, worktree=worktree, task="adopt_merger_pr_lease"
)
verify_preflight_purity(remote, task="adopt_merger_pr_lease")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
profile = get_profile()
profile_name = profile.get("profile_name") or "unknown"
identity = _authenticated_username(remote) or profile.get("username") or ""
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
repo_label = f"{o}/{r}"
pr_live = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
pr_state = (pr_live.get("state") or "").strip().lower()
pr_open = pr_state == "open" and not pr_live.get("merged")
live_head = (pr_live.get("head", {}) or {}).get("sha") or pr_live.get(
"head_sha"
)
feedback = gitea_get_pr_review_feedback(
pr_number=pr_number,
remote=remote,
host=host,
org=org,
repo=repo,
)
approval_at_head = bool(feedback.get("approval_at_current_head"))
comments = _fetch_pr_comments(
pr_number, remote=remote, host=host, org=org, repo=repo)
assessment = merger_lease_adoption.assess_adopt_merger_lease(
comments,
pr_number=pr_number,
adopter_identity=identity,
adopter_profile=profile_name,
adopter_session_id=sid,
repo=repo_label,
issue_number=issue_number,
worktree=worktree,
expected_head_sha=expected_head_sha,
live_head_sha=live_head,
approval_at_head=approval_at_head,
pr_open=pr_open,
)
if not assessment.get("adopt_allowed"):
return {
"success": False,
"adopted": False,
"pr_number": pr_number,
"reasons": assessment.get("reasons") or [],
"active_lease": assessment.get("active_lease"),
"expected_head_sha": expected_head_sha,
"live_head_sha": live_head,
}
active = assessment.get("active_lease") or {}
body = assessment["adoption_body"]
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
with _audited(
"comment_pr",
host=h,
remote=remote,
org=o,
repo=r,
pr_number=pr_number,
request_metadata={"source": "adopt_merger_pr_lease"},
):
posted = api_request("POST", comment_url, auth, {"body": body})
provenance = merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_ADOPT,
comment_id=posted.get("id"),
adopted_from_session_id=active.get("session_id"),
adopted_from_profile=active.get("profile"),
adopted_from_reviewer_identity=active.get("reviewer_identity"),
adoption_reason=merger_lease_adoption.DEFAULT_ADOPTION_REASON,
)
session_lease = reviewer_pr_lease.record_session_lease({
"pr_number": pr_number,
"issue_number": issue_number or active.get("issue_number"),
"session_id": sid,
"reviewer_identity": identity,
"profile": profile_name,
"worktree": worktree,
"phase": "adopted",
"candidate_head": live_head,
"target_branch": active.get("target_branch") or "master",
"target_branch_sha": active.get("target_branch_sha"),
"repo": repo_label,
"comment_id": posted.get("id"),
}, lease_provenance=provenance)
return {
"success": True,
"adopted": True,
"pr_number": pr_number,
"session_id": sid,
"adoption_comment_id": posted.get("id"),
"adopted_from_session_id": active.get("session_id"),
"adopted_from_profile": active.get("profile"),
"adopted_from_reviewer_identity": active.get("reviewer_identity"),
"adoption_reason": merger_lease_adoption.DEFAULT_ADOPTION_REASON,
"expected_head_sha": expected_head_sha,
"live_head_sha": live_head,
"session_lease": session_lease,
"lease_provenance": provenance,
"reasons": [],
}
@mcp.tool()
def gitea_heartbeat_reviewer_pr_lease(
pr_number: int,
@@ -5422,6 +5596,17 @@ def gitea_heartbeat_reviewer_pr_lease(
):
posted = api_request("POST", comment_url, auth, {"body": body})
prior_provenance = (session.get("lease_provenance") or {}).copy()
heartbeat_provenance = merger_lease_adoption.build_lease_provenance(
source=merger_lease_adoption.SOURCE_HEARTBEAT,
comment_id=posted.get("id"),
adopted_from_session_id=prior_provenance.get("adopted_from_session_id"),
adopted_from_profile=prior_provenance.get("adopted_from_profile"),
adopted_from_reviewer_identity=prior_provenance.get(
"adopted_from_reviewer_identity"
),
adoption_reason=prior_provenance.get("adoption_reason"),
)
updated = reviewer_pr_lease.record_session_lease({
**session,
"phase": phase,
@@ -5429,7 +5614,8 @@ def gitea_heartbeat_reviewer_pr_lease(
"candidate_head": candidate_head or session.get("candidate_head"),
"target_branch_sha": target_branch_sha or session.get("target_branch_sha"),
"last_comment_id": posted.get("id"),
})
"comment_id": posted.get("id"),
}, lease_provenance=heartbeat_provenance)
return {
"success": True,
"posted": True,