feat: require approval at current PR head for merge (#471)

Add merge_approval_gate assessment so gitea_merge_pr fails closed when
visible APPROVED reviews do not pin the live head SHA. Expose
approval_at_current_head and stale_approval_block_reason in review
feedback. Document re-review requirement in canonical merge workflow.

Closes #471.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-08 00:37:54 -04:00
co-authored by Claude Opus 4.8
parent a1de4ab8f9
commit 89582a0f2a
7 changed files with 184 additions and 1 deletions
+21
View File
@@ -535,6 +535,7 @@ import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_adoption # noqa: E402
import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
@@ -2472,6 +2473,10 @@ def gitea_get_pr_review_feedback(
e for e in latest_by_reviewer.values()
if e["verdict"] == "APPROVED" and not e["dismissed"]
]
approval_head = merge_approval_gate.assess_merge_approval_head(
current_head_sha=current_head,
latest_by_reviewer=latest_by_reviewer,
)
return {
"success": True,
"pr_number": pr_number,
@@ -2482,6 +2487,9 @@ def gitea_get_pr_review_feedback(
login: e["verdict"] for login, e in latest_by_reviewer.items()},
"has_blocking_change_requests": bool(blocking),
"approval_visible": bool(approvals),
"approval_at_current_head": approval_head["approval_at_current_head"],
"latest_approved_head_sha": approval_head["latest_approved_head_sha"],
"stale_approval_block_reason": approval_head["stale_approval_block_reason"],
"latest_reviewed_head_sha": latest_reviewed_head,
"review_feedback_stale": bool(
latest_reviewed_head and current_head
@@ -3442,6 +3450,9 @@ def gitea_merge_pr(
result["permission_report"] = feedback["permission_report"]
return result
result["approval_visible"] = feedback.get("approval_visible")
result["approval_at_current_head"] = feedback.get("approval_at_current_head")
result["latest_approved_head_sha"] = feedback.get("latest_approved_head_sha")
result["review_feedback_stale"] = feedback.get("review_feedback_stale")
result["has_blocking_change_requests"] = feedback.get(
"has_blocking_change_requests")
if feedback.get("has_blocking_change_requests"):
@@ -3455,6 +3466,16 @@ def gitea_merge_pr(
"completed before merge (fail closed)"
)
return result
if not feedback.get("approval_at_current_head"):
reasons.append(
feedback.get("stale_approval_block_reason")
or (
"approval does not apply to current PR head SHA "
"(fail closed); required next action: re-review PR at "
"current head before merge"
)
)
return result
# Gate 8 — in-process mutation authority (#199): the last check before
# the merge mutation, using the identity the eligibility gate proved.