Already-landed open PRs block the review queue: the already-landed gate correctly stops approval/merge, but no task path could close the redundant PR. Add the reconciler close path: - task_capability_map: new reconcile_close_landed_pr (gitea.pr.close) and reconcile_close_landed_issue (gitea.issue.close) tasks under a dedicated "reconciler" role. Exact close capabilities only — the role grants no review, approve, request-changes, or merge authority, and normal reviewer profiles keep no broad PR-close authority. - role_session_router: reconciler tasks route wrong_role_stop in author/reviewer sessions with a dedicated recovery message; matching reconciler sessions route allowed_current_session. - review_proofs.assess_reconciler_close_gate: PR close allowed only when every proof passes — PR live-verified open, candidate head SHA pinned and matching the live head, target branch freshly fetched with a full SHA, head an ancestor of the target, exact gitea.pr.close proven. Non-landed PRs return NOT_LANDED_CLOSE_BLOCKED; missing close capability returns RECOVERY_HANDOFF_REQUIRED; incomplete proof fails closed as GATE_NOT_PROVEN. Linked-issue close is skipped when the issue is already closed and allowed only for an open issue resolved by the landed commits with exact gitea.issue.close capability. The gate also returns the required final-report field list. 17 new tests cover the capability map, wrong-role routing, and every gate path (landed close, non-landed block, stale target, changed head, missing capabilities, linked-issue variants, review-mutation denial). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
189 lines
6.9 KiB
Python
189 lines
6.9 KiB
Python
"""Issue #309: dedicated reconciler capability to close already-landed PRs.
|
|
|
|
The reconciler path must prove exact ``gitea.pr.close`` capability, must
|
|
never imply review/approve/request-changes/merge capability, and may close
|
|
a PR only after full already-landed proof.
|
|
"""
|
|
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
|
|
import role_session_router # noqa: E402
|
|
import task_capability_map # noqa: E402
|
|
from review_proofs import assess_reconciler_close_gate # noqa: E402
|
|
|
|
PINNED = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
|
OTHER = "a4060c5de00f2b1c9e88f4f6f0f3f9a7b2c1d0e9"
|
|
|
|
|
|
class TestReconcilerCapabilityMap(unittest.TestCase):
|
|
def test_reconcile_close_pr_task_maps_to_pr_close(self):
|
|
self.assertEqual(
|
|
task_capability_map.required_permission(
|
|
"reconcile_close_landed_pr"),
|
|
"gitea.pr.close",
|
|
)
|
|
self.assertEqual(
|
|
task_capability_map.required_role("reconcile_close_landed_pr"),
|
|
"reconciler",
|
|
)
|
|
|
|
def test_reconcile_close_issue_task_maps_to_issue_close(self):
|
|
self.assertEqual(
|
|
task_capability_map.required_permission(
|
|
"reconcile_close_landed_issue"),
|
|
"gitea.issue.close",
|
|
)
|
|
self.assertEqual(
|
|
task_capability_map.required_role(
|
|
"reconcile_close_landed_issue"),
|
|
"reconciler",
|
|
)
|
|
|
|
def test_reconciler_tasks_do_not_grant_review_permissions(self):
|
|
for task in ("reconcile_close_landed_pr",
|
|
"reconcile_close_landed_issue"):
|
|
permission = task_capability_map.required_permission(task)
|
|
self.assertNotIn("review", permission)
|
|
self.assertNotIn("approve", permission)
|
|
self.assertNotIn("merge", permission)
|
|
self.assertNotIn("request_changes", permission)
|
|
|
|
|
|
class TestReconcilerRouting(unittest.TestCase):
|
|
def test_reconciler_task_requires_reconciler_role(self):
|
|
self.assertEqual(
|
|
role_session_router.required_role_for_task(
|
|
"reconcile_close_landed_pr"),
|
|
"reconciler",
|
|
)
|
|
|
|
def test_reconciler_task_blocked_in_author_session(self):
|
|
result = role_session_router.route_task_session(
|
|
"reconcile_close_landed_pr",
|
|
active_profile="prgs-author",
|
|
active_role_kind="author",
|
|
allowed_in_current_session=False,
|
|
)
|
|
self.assertEqual(result["route_result"], "wrong_role_stop")
|
|
self.assertFalse(result["downstream_allowed"])
|
|
|
|
def test_reconciler_task_allowed_in_matching_session(self):
|
|
result = role_session_router.route_task_session(
|
|
"reconcile_close_landed_pr",
|
|
active_profile="prgs-reconciler",
|
|
active_role_kind="reconciler",
|
|
allowed_in_current_session=True,
|
|
)
|
|
self.assertEqual(result["route_result"], "allowed_current_session")
|
|
self.assertTrue(result["downstream_allowed"])
|
|
|
|
|
|
class TestReconcilerCloseGate(unittest.TestCase):
|
|
def _gate(self, **overrides):
|
|
kwargs = {
|
|
"pr_number": 278,
|
|
"pr_state": "open",
|
|
"candidate_head_sha": PINNED,
|
|
"live_head_sha": PINNED,
|
|
"target_branch": "master",
|
|
"target_branch_sha": OTHER,
|
|
"head_is_ancestor_of_target": True,
|
|
"close_pr_capability": True,
|
|
"close_issue_capability": False,
|
|
"linked_issue_state": "closed",
|
|
}
|
|
kwargs.update(overrides)
|
|
return assess_reconciler_close_gate(**kwargs)
|
|
|
|
def test_already_landed_open_pr_close_allowed(self):
|
|
result = self._gate()
|
|
self.assertEqual(result["outcome"], "CLOSE_ALLOWED")
|
|
self.assertTrue(result["pr_close_allowed"])
|
|
|
|
def test_not_landed_pr_cannot_be_closed(self):
|
|
result = self._gate(head_is_ancestor_of_target=False)
|
|
self.assertEqual(result["outcome"], "NOT_LANDED_CLOSE_BLOCKED")
|
|
self.assertFalse(result["pr_close_allowed"])
|
|
|
|
def test_unchecked_ancestry_blocks_close(self):
|
|
result = self._gate(head_is_ancestor_of_target=None)
|
|
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
|
self.assertFalse(result["pr_close_allowed"])
|
|
|
|
def test_stale_target_branch_sha_blocks_close(self):
|
|
result = self._gate(target_branch_sha=None)
|
|
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
|
self.assertFalse(result["pr_close_allowed"])
|
|
|
|
def test_closed_pr_state_blocks_close(self):
|
|
result = self._gate(pr_state="closed")
|
|
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
|
self.assertFalse(result["pr_close_allowed"])
|
|
|
|
def test_changed_head_blocks_close(self):
|
|
result = self._gate(live_head_sha=OTHER)
|
|
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
|
self.assertFalse(result["pr_close_allowed"])
|
|
|
|
def test_missing_close_capability_produces_recovery_handoff(self):
|
|
result = self._gate(close_pr_capability=False)
|
|
self.assertEqual(result["outcome"], "RECOVERY_HANDOFF_REQUIRED")
|
|
self.assertFalse(result["pr_close_allowed"])
|
|
self.assertTrue(any(
|
|
"gitea.pr.close" in reason for reason in result["reasons"]
|
|
))
|
|
|
|
def test_linked_issue_already_closed_skips_issue_close(self):
|
|
result = self._gate(linked_issue_state="closed",
|
|
close_issue_capability=True)
|
|
self.assertFalse(result["issue_close_allowed"])
|
|
|
|
def test_linked_open_issue_close_requires_capability(self):
|
|
allowed = self._gate(
|
|
linked_issue_state="open",
|
|
issue_resolved_by_landing=True,
|
|
close_issue_capability=True,
|
|
)
|
|
self.assertTrue(allowed["issue_close_allowed"])
|
|
|
|
blocked = self._gate(
|
|
linked_issue_state="open",
|
|
issue_resolved_by_landing=True,
|
|
close_issue_capability=False,
|
|
)
|
|
self.assertFalse(blocked["issue_close_allowed"])
|
|
|
|
def test_gate_never_allows_review_mutations(self):
|
|
for result in (
|
|
self._gate(),
|
|
self._gate(head_is_ancestor_of_target=False),
|
|
self._gate(close_pr_capability=False),
|
|
):
|
|
self.assertFalse(result["review_allowed"])
|
|
self.assertFalse(result["approve_allowed"])
|
|
self.assertFalse(result["request_changes_allowed"])
|
|
self.assertFalse(result["merge_allowed"])
|
|
|
|
def test_report_fields_listed_for_close(self):
|
|
result = self._gate()
|
|
for field in (
|
|
"identity/profile",
|
|
"close capability proof",
|
|
"PR live state",
|
|
"candidate head SHA",
|
|
"target branch SHA",
|
|
"ancestor proof",
|
|
"linked issue status",
|
|
"PR close result",
|
|
"issue close result",
|
|
"no review/merge confirmation",
|
|
):
|
|
self.assertIn(field, result["required_report_fields"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|