fix(cleanup): post-delete readback and active ownership gates (#687)
Require authoritative not-found readback after merged-PR branch DELETE, and block cleanup when active author/reviewer/merger/controller/reconciler session, lease, or worktree-binding ownership still uses the target branch.
This commit is contained in:
@@ -92,6 +92,11 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
||||
return_value=True,
|
||||
).start()
|
||||
# Default: no active ownership records (tests that need ownership patch this).
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[],
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
@@ -176,17 +181,31 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
"mcp_server.get_profile",
|
||||
return_value=dict(RECONCILER_WITH_DELETE),
|
||||
).start()
|
||||
self.mock_api.side_effect = [
|
||||
{
|
||||
"number": 487,
|
||||
"merged": True,
|
||||
"merged_at": "2026-07-08T01:00:00Z",
|
||||
"head": {"ref": branch, "sha": "a" * 40},
|
||||
"base": {"ref": "master"},
|
||||
},
|
||||
{},
|
||||
{},
|
||||
]
|
||||
|
||||
def _api(method, url, *args, **kwargs):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return {
|
||||
"number": 487,
|
||||
"merged": True,
|
||||
"merged_at": "2026-07-08T01:00:00Z",
|
||||
"head": {"ref": branch, "sha": "a" * 40},
|
||||
"base": {"ref": "master"},
|
||||
}
|
||||
if method == "GET" and "/branches/" in url:
|
||||
# First pre-delete probe: present. Post-delete: not found.
|
||||
get_branch_calls = [
|
||||
c
|
||||
for c in self.mock_api.call_args_list
|
||||
if c.args and c.args[0] == "GET" and "/branches/" in c.args[1]
|
||||
]
|
||||
if len(get_branch_calls) <= 1:
|
||||
return {"name": branch}
|
||||
raise RuntimeError("HTTP 404: not found")
|
||||
if method == "DELETE":
|
||||
return {}
|
||||
raise AssertionError(f"unexpected {method} {url}")
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
@@ -194,7 +213,9 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertTrue(res["performed"])
|
||||
self.assertTrue((res.get("readback") or {}).get("verified_absent"))
|
||||
delete_calls = [
|
||||
call for call in self.mock_api.call_args_list if call.args[0] == "DELETE"
|
||||
]
|
||||
@@ -394,5 +415,433 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
|
||||
class TestPostDeleteReadback(unittest.TestCase):
|
||||
def test_not_found_is_verified_success(self):
|
||||
readback = guard.classify_branch_readback_http_status(404)
|
||||
result = guard.assess_post_delete_readback(readback)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(result["readback"]["verified_absent"])
|
||||
|
||||
def test_exists_is_structured_failure(self):
|
||||
readback = guard.classify_branch_readback_http_status(200)
|
||||
result = guard.assess_post_delete_readback(readback)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertFalse(result["readback"]["verified_absent"])
|
||||
self.assertTrue(result["readback"]["branch_present"])
|
||||
self.assertIn("still present", " ".join(result["reasons"]))
|
||||
|
||||
def test_auth_failure_preserved(self):
|
||||
readback = guard.classify_branch_readback_http_status(401)
|
||||
result = guard.assess_post_delete_readback(readback)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["readback"]["error_class"], "authentication")
|
||||
self.assertIn("authentication", " ".join(result["reasons"]))
|
||||
|
||||
def test_authz_and_transport_failures(self):
|
||||
for code, err in ((403, "authorization"), (503, "transport")):
|
||||
with self.subTest(code=code):
|
||||
readback = guard.classify_branch_readback_http_status(code)
|
||||
result = guard.assess_post_delete_readback(readback)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["readback"]["error_class"], err)
|
||||
|
||||
def test_exception_classifier_no_secret_leak(self):
|
||||
class FakeHTTPError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("HTTP 401: token=super-secret-value Authorization: Bearer xyz")
|
||||
self.code = 401
|
||||
|
||||
result = guard.classify_branch_readback_exception(FakeHTTPError())
|
||||
blob = str(result)
|
||||
self.assertNotIn("super-secret", blob)
|
||||
self.assertNotIn("Bearer", blob)
|
||||
self.assertEqual(result["error_class"], "authentication")
|
||||
|
||||
|
||||
class TestActiveBranchOwnership(unittest.TestCase):
|
||||
def _base(self, **overrides):
|
||||
rec = {
|
||||
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
"status": "active",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"branch": "feat/target",
|
||||
"reclaim_allowed": False,
|
||||
}
|
||||
rec.update(overrides)
|
||||
return rec
|
||||
|
||||
def test_active_author_blocks(self):
|
||||
result = guard.assess_active_branch_ownership(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch="feat/target",
|
||||
records=[self._base()],
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn(guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, result["blocking_categories"])
|
||||
|
||||
def test_active_reviewer_lease_blocks(self):
|
||||
result = guard.assess_active_branch_ownership(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch="feat/target",
|
||||
records=[
|
||||
self._base(
|
||||
category=guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||
status="active",
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn(guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, result["blocking_categories"])
|
||||
|
||||
def test_merger_controller_reconciler_binding_blocks(self):
|
||||
for cat in (
|
||||
guard.OWNERSHIP_CATEGORY_MERGER_LEASE,
|
||||
guard.OWNERSHIP_CATEGORY_CONTROLLER_LEASE,
|
||||
guard.OWNERSHIP_CATEGORY_RECONCILER_LEASE,
|
||||
guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||
):
|
||||
with self.subTest(category=cat):
|
||||
result = guard.assess_active_branch_ownership(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch="feat/target",
|
||||
records=[self._base(category=cat, status="active")],
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn(cat, result["blocking_categories"])
|
||||
|
||||
def test_released_and_expired_reclaimable_do_not_block(self):
|
||||
records = [
|
||||
self._base(status="released", reclaim_allowed=True),
|
||||
self._base(
|
||||
category=guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||
status="expired",
|
||||
reclaim_allowed=True,
|
||||
),
|
||||
]
|
||||
result = guard.assess_active_branch_ownership(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch="feat/target",
|
||||
records=records,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_sticky_stale_blocks_per_recovery_policy(self):
|
||||
result = guard.assess_active_branch_ownership(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch="feat/target",
|
||||
records=[self._base(status="stale", reclaim_allowed=False)],
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("sticky", " ".join(result["reasons"]))
|
||||
|
||||
def test_other_repo_or_branch_no_false_block(self):
|
||||
records = [
|
||||
self._base(repo="Other-Repo", status="active"),
|
||||
self._base(branch="feat/other", status="active"),
|
||||
self._base(remote="dadeschools", status="active"),
|
||||
]
|
||||
result = guard.assess_active_branch_ownership(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch="feat/target",
|
||||
records=records,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(len(result["ignored_out_of_scope"]), 3)
|
||||
|
||||
def test_denial_has_no_secrets(self):
|
||||
result = guard.assess_active_branch_ownership(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch="feat/target",
|
||||
records=[
|
||||
self._base(
|
||||
status="active",
|
||||
# Poison fields that must never be echoed as secrets
|
||||
token="sekrit-token",
|
||||
authorization="Bearer abc",
|
||||
)
|
||||
],
|
||||
)
|
||||
blob = str(result)
|
||||
self.assertNotIn("sekrit", blob)
|
||||
self.assertNotIn("Bearer", blob)
|
||||
self.assertIn("author_lease", blob)
|
||||
|
||||
|
||||
class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(
|
||||
mcp_server.REMOTES,
|
||||
{
|
||||
"prgs": {
|
||||
"host": "gitea.example.com",
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
}
|
||||
},
|
||||
)
|
||||
self._remotes.start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
||||
return_value=True,
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.get_profile",
|
||||
return_value=dict(RECONCILER_WITH_DELETE),
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def _pr_payload(self, branch, number=487):
|
||||
return {
|
||||
"number": number,
|
||||
"merged": True,
|
||||
"merged_at": "2026-07-08T01:00:00Z",
|
||||
"head": {"ref": branch, "sha": "a" * 40},
|
||||
"base": {"ref": "master"},
|
||||
}
|
||||
|
||||
def test_delete_success_but_branch_remains(self):
|
||||
branch = "feat/still-there"
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[],
|
||||
).start()
|
||||
|
||||
def _api(method, url, *a, **k):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return self._pr_payload(branch)
|
||||
if method == "GET" and "/branches/" in url:
|
||||
return {"name": branch} # present before and after
|
||||
if method == "DELETE":
|
||||
return {}
|
||||
raise AssertionError(method)
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
branch=branch,
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertTrue(res.get("performed"))
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertTrue(res.get("delete_acknowledged"))
|
||||
self.assertIn("still present", " ".join(res.get("reasons") or []))
|
||||
|
||||
def test_readback_authentication_failure(self):
|
||||
branch = "feat/auth-fail-readback"
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[],
|
||||
).start()
|
||||
state = {"branch_gets": 0}
|
||||
|
||||
def _api(method, url, *a, **k):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return self._pr_payload(branch)
|
||||
if method == "GET" and "/branches/" in url:
|
||||
state["branch_gets"] += 1
|
||||
if state["branch_gets"] == 1:
|
||||
return {"name": branch}
|
||||
raise RuntimeError("HTTP 401: unauthorized")
|
||||
if method == "DELETE":
|
||||
return {}
|
||||
raise AssertionError(method)
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
branch=branch,
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertTrue(res.get("performed"))
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertEqual((res.get("readback") or {}).get("error_class"), "authentication")
|
||||
|
||||
def test_readback_transport_failure(self):
|
||||
branch = "feat/transport-fail-readback"
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[],
|
||||
).start()
|
||||
state = {"branch_gets": 0}
|
||||
|
||||
def _api(method, url, *a, **k):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return self._pr_payload(branch)
|
||||
if method == "GET" and "/branches/" in url:
|
||||
state["branch_gets"] += 1
|
||||
if state["branch_gets"] == 1:
|
||||
return {"name": branch}
|
||||
raise RuntimeError("HTTP 503: temporarily unavailable")
|
||||
if method == "DELETE":
|
||||
return {}
|
||||
raise AssertionError(method)
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
branch=branch,
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertEqual((res.get("readback") or {}).get("error_class"), "transport")
|
||||
|
||||
def test_active_author_ownership_blocks_before_delete(self):
|
||||
branch = "feat/owned"
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[
|
||||
{
|
||||
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
"status": "active",
|
||||
"remote": "prgs",
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
"branch": branch,
|
||||
"reclaim_allowed": False,
|
||||
}
|
||||
],
|
||||
).start()
|
||||
|
||||
def _api(method, url, *a, **k):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return self._pr_payload(branch)
|
||||
if method == "GET" and "/branches/" in url:
|
||||
return {"name": branch}
|
||||
raise AssertionError(f"unexpected mutation {method}")
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
branch=branch,
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertFalse(res.get("performed"))
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertEqual(res.get("blocker_kind"), "active_branch_ownership")
|
||||
self.assertIn(
|
||||
guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||
(res.get("ownership") or {}).get("blocking_categories") or [],
|
||||
)
|
||||
delete_calls = [
|
||||
c for c in self.mock_api.call_args_list if c.args and c.args[0] == "DELETE"
|
||||
]
|
||||
self.assertFalse(delete_calls)
|
||||
|
||||
def test_open_pr_guard_still_blocks(self):
|
||||
branch = "feat/open-pr-head"
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[],
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.api_get_all",
|
||||
return_value=[{"head": {"ref": branch}, "number": 999}],
|
||||
).start()
|
||||
|
||||
def _api(method, url, *a, **k):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return self._pr_payload(branch, number=487)
|
||||
if method == "GET" and "/branches/" in url:
|
||||
return {"name": branch}
|
||||
raise AssertionError(method)
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
branch=branch,
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertFalse(res.get("performed"))
|
||||
self.assertTrue(any("open PR" in r for r in (res.get("reasons") or [])))
|
||||
|
||||
def test_non_ancestor_guard_still_blocks(self):
|
||||
branch = "feat/not-ancestor"
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[],
|
||||
).start()
|
||||
patch(
|
||||
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
|
||||
return_value=False,
|
||||
).start()
|
||||
|
||||
def _api(method, url, *a, **k):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return self._pr_payload(branch)
|
||||
if method == "GET" and "/branches/" in url:
|
||||
return {"name": branch}
|
||||
raise AssertionError(method)
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
branch=branch,
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertFalse(res.get("performed"))
|
||||
self.assertTrue(any("ancestor" in r for r in (res.get("reasons") or [])))
|
||||
|
||||
def test_protected_default_branch_guard(self):
|
||||
branch = "master"
|
||||
patch(
|
||||
"mcp_server._collect_branch_ownership_records",
|
||||
return_value=[],
|
||||
).start()
|
||||
|
||||
def _api(method, url, *a, **k):
|
||||
if method == "GET" and "/pulls/" in url:
|
||||
return self._pr_payload(branch)
|
||||
if method == "GET" and "/branches/" in url:
|
||||
return {"name": branch}
|
||||
raise AssertionError(method)
|
||||
|
||||
self.mock_api.side_effect = _api
|
||||
res = gitea_cleanup_merged_pr_branch(
|
||||
pr_number=487,
|
||||
confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}",
|
||||
branch=branch,
|
||||
remote="prgs",
|
||||
worktree_path="/tmp/repo/branches/cleanup",
|
||||
)
|
||||
self.assertFalse(res.get("performed"))
|
||||
self.assertTrue(any("protected" in r for r in (res.get("reasons") or [])))
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user