feat: comment-then-stop policy for partial PR reconciliation (Closes #302)
Already-landed PR reconciliation could prove a PR should be closed but stop silently when gitea.pr.close was unavailable, even with comment capability present. Adopt Option B (comment-then-stop) as the durable policy and enforce it: - resolve_partial_reconciliation_plan maps proven capabilities to one of four outcomes: FULL_RECONCILE_CLOSE_ALLOWED (close_pr proven), PARTIAL_RECONCILE_COMMENT_THEN_STOP (comment_pr proven, close_pr missing; one reconciliation comment with PR head SHA, target branch SHA, ancestor proof, linked issue status, and the missing capability, then stop), RECOVERY_HANDOFF_ONLY (no comment capability; no Gitea mutation), and GATE_NOT_PROVEN (no ancestry proof; no mutation regardless of capability). Missing capability dicts fail closed. - assess_partial_reconciliation_report requires final reports to explain why the comment was or was not posted, name the missing capability, and state the close result or that no mutation ran. - workflows/reconcile-landed-pr.md gains section 12A documenting the policy; the workflow contract test locks the new markers. Tests cover close capability present, close missing with comment allowed, comment capability missing, all capabilities missing, unproven ancestry, fail-closed capability dict, and report checks for each outcome. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -24,6 +24,8 @@ from review_proofs import ( # noqa: E402
|
||||
ISSUE_SELECTION_CONTINUATION_EXPLICIT,
|
||||
ISSUE_SELECTION_REPRESENTED_BY_OPEN_PR,
|
||||
assess_author_pr_report,
|
||||
assess_partial_reconciliation_report,
|
||||
resolve_partial_reconciliation_plan,
|
||||
assess_queue_ordering_proof,
|
||||
assess_reviewer_baseline_validation_proof,
|
||||
assess_capability_evidence,
|
||||
@@ -2539,5 +2541,132 @@ class TestReviewerBaselineValidationProof(unittest.TestCase):
|
||||
self.assertFalse(report["baseline_validation_proven"])
|
||||
|
||||
|
||||
class TestPartialReconciliationPlan(unittest.TestCase):
|
||||
"""Issue #302: policy when PR-close capability is missing (Option B)."""
|
||||
|
||||
def _plan(self, **overrides):
|
||||
kwargs = {
|
||||
"ancestor_proven": True,
|
||||
"capabilities": {
|
||||
"close_pr": False,
|
||||
"comment_pr": True,
|
||||
"close_issue": True,
|
||||
"comment_issue": True,
|
||||
},
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return resolve_partial_reconciliation_plan(**kwargs)
|
||||
|
||||
def test_close_capability_present_allows_full_reconcile(self):
|
||||
plan = self._plan(capabilities={
|
||||
"close_pr": True, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "FULL_RECONCILE_CLOSE_ALLOWED")
|
||||
self.assertIn("close_pr", plan["allowed_mutations"])
|
||||
|
||||
def test_close_missing_with_comment_posts_comment_then_stops(self):
|
||||
plan = self._plan()
|
||||
self.assertEqual(
|
||||
plan["outcome"], "PARTIAL_RECONCILE_COMMENT_THEN_STOP")
|
||||
self.assertEqual(plan["allowed_mutations"], ("comment_pr",))
|
||||
for field in (
|
||||
"PR head SHA",
|
||||
"target branch SHA",
|
||||
"ancestor proof",
|
||||
"linked issue status",
|
||||
"required missing capability",
|
||||
):
|
||||
self.assertIn(field, plan["required_comment_fields"])
|
||||
|
||||
def test_comment_capability_missing_produces_handoff_only(self):
|
||||
plan = self._plan(capabilities={
|
||||
"close_pr": False, "comment_pr": False,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "RECOVERY_HANDOFF_ONLY")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
def test_all_capabilities_missing_produces_handoff_only(self):
|
||||
plan = self._plan(capabilities={
|
||||
"close_pr": False, "comment_pr": False,
|
||||
"close_issue": False, "comment_issue": False,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "RECOVERY_HANDOFF_ONLY")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
def test_unproven_ancestry_blocks_all_mutations(self):
|
||||
plan = self._plan(ancestor_proven=False, capabilities={
|
||||
"close_pr": True, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
})
|
||||
self.assertEqual(plan["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
def test_missing_capability_dict_fails_closed(self):
|
||||
plan = self._plan(capabilities=None)
|
||||
self.assertEqual(plan["outcome"], "RECOVERY_HANDOFF_ONLY")
|
||||
self.assertEqual(plan["allowed_mutations"], ())
|
||||
|
||||
|
||||
class TestPartialReconciliationReport(unittest.TestCase):
|
||||
"""Issue #302: reports must explain why comments were or were not posted."""
|
||||
|
||||
def _plan(self, outcome):
|
||||
capabilities = {
|
||||
"FULL_RECONCILE_CLOSE_ALLOWED": {
|
||||
"close_pr": True, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
},
|
||||
"PARTIAL_RECONCILE_COMMENT_THEN_STOP": {
|
||||
"close_pr": False, "comment_pr": True,
|
||||
"close_issue": True, "comment_issue": True,
|
||||
},
|
||||
"RECOVERY_HANDOFF_ONLY": {
|
||||
"close_pr": False, "comment_pr": False,
|
||||
"close_issue": False, "comment_issue": False,
|
||||
},
|
||||
}[outcome]
|
||||
return resolve_partial_reconciliation_plan(
|
||||
ancestor_proven=True, capabilities=capabilities)
|
||||
|
||||
def test_partial_report_requires_comment_and_missing_capability(self):
|
||||
plan = self._plan("PARTIAL_RECONCILE_COMMENT_THEN_STOP")
|
||||
good = (
|
||||
"Reconciliation comment posted on PR #278 with ancestor proof. "
|
||||
"PR close skipped: close capability gitea.pr.close missing; "
|
||||
"stopped for authorized close."
|
||||
)
|
||||
result = assess_partial_reconciliation_report(good, plan=plan)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
bad = "Stopped. Nothing done."
|
||||
result = assess_partial_reconciliation_report(bad, plan=plan)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_handoff_only_report_requires_no_comment_explanation(self):
|
||||
plan = self._plan("RECOVERY_HANDOFF_ONLY")
|
||||
good = (
|
||||
"No reconciliation comment posted: comment capability missing. "
|
||||
"No Gitea mutation performed; recovery handoff produced."
|
||||
)
|
||||
result = assess_partial_reconciliation_report(good, plan=plan)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
bad = "Recovery handoff produced."
|
||||
result = assess_partial_reconciliation_report(bad, plan=plan)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_reconcile_report_requires_close_result(self):
|
||||
plan = self._plan("FULL_RECONCILE_CLOSE_ALLOWED")
|
||||
good = "PR close result: closed PR #278 after ancestor proof."
|
||||
result = assess_partial_reconciliation_report(good, plan=plan)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
bad = "Reconciliation done."
|
||||
result = assess_partial_reconciliation_report(bad, plan=plan)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user