feat: add merged PR cleanup reconciliation report (#269)

Implement gitea_reconcile_merged_cleanups with dry-run reporting for merged
PR remote branches and local branches/ worktrees, plus confirmation-gated
execute mode. Safety gates cover protected branches, open PR references,
active issue locks, dirty worktrees, and delete_branch capability.

Closes #269

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 00:16:42 -04:00
co-authored by Claude Opus 4.8
parent d6f4f936e3
commit 4cc31a42f9
5 changed files with 687 additions and 0 deletions
+55
View File
@@ -24,6 +24,7 @@ from mcp_server import ( # noqa: E402
gitea_merge_pr,
gitea_review_pr,
gitea_delete_branch,
gitea_reconcile_merged_cleanups,
gitea_edit_pr,
gitea_get_file,
gitea_commit_files,
@@ -990,6 +991,60 @@ class TestDeleteBranch(unittest.TestCase):
self.assertIn("feat%2Fbranch", url)
# ---------------------------------------------------------------------------
# Reconcile merged cleanups (#269)
# ---------------------------------------------------------------------------
READ_ENV = {
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
}
class TestReconcileMergedCleanups(unittest.TestCase):
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_dry_run_builds_report_without_mutations(self, _auth, mock_api_get_all):
mock_api_get_all.side_effect = [
[
{
"number": 50,
"title": "merged feature",
"body": "Closes #50",
"merged": True,
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "deadbeef",
"head": {"ref": "feat/issue-50-example", "sha": "cafebabe"},
}
],
[],
]
with patch.dict(os.environ, READ_ENV, clear=True):
with patch(
"mcp_server._remote_branch_exists",
return_value=True,
):
with patch(
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
return_value=True,
):
result = gitea_reconcile_merged_cleanups(
dry_run=True,
remote="prgs",
limit=10,
)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
self.assertFalse(result["executed"])
self.assertEqual(result["merged_pr_count"], 1)
self.assertEqual(result["entries"][0]["issue_number"], 50)
def test_execute_requires_confirmation(self):
with patch.dict(os.environ, READ_ENV, clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_reconcile_merged_cleanups(dry_run=False, execute_confirmed=False)
self.assertIn("execute_confirmed", str(ctx.exception))
# ---------------------------------------------------------------------------
# Edit PR
# ---------------------------------------------------------------------------
+159
View File
@@ -0,0 +1,159 @@
"""Tests for merged PR cleanup reconciliation (#269)."""
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import merged_cleanup_reconcile as mcr # noqa: E402
class TestMergedCleanupAssessment(unittest.TestCase):
def test_extract_linked_issue_from_closes(self):
issue = mcr.extract_linked_issue(
"feat: cleanup (Closes #269)",
"No other refs",
)
self.assertEqual(issue, 269)
def test_merged_remote_branch_safe_when_gates_pass(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-269-merged-pr-cleanup-reconcile",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertTrue(result["safe_to_delete_remote"])
self.assertEqual(result["block_reasons"], [])
def test_open_pr_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads={"feat/issue-9-example"},
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("open PR", result["block_reasons"][0])
def test_protected_branch_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=1,
head_branch="master",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("protected", result["block_reasons"][0])
def test_active_lock_blocks_remote_and_worktree_cleanup(self):
remote = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=True,
)
local = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=True,
)
self.assertFalse(remote["safe_to_delete_remote"])
self.assertFalse(local["safe_to_remove_worktree"])
self.assertIn("active issue lock", remote["block_reasons"][0])
def test_dirty_worktree_blocks_local_cleanup(self):
result = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": False,
"dirty_files": ["gitea_mcp_server.py"],
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=False,
)
self.assertFalse(result["safe_to_remove_worktree"])
self.assertIn("tracked edits", result["block_reasons"][0])
class TestMergedCleanupReport(unittest.TestCase):
def test_build_report_skips_unmerged_closed_prs(self):
with tempfile.TemporaryDirectory() as tmp:
report = mcr.build_reconciliation_report(
project_root=tmp,
closed_prs=[
{
"number": 10,
"title": "closed not merged",
"body": "Closes #10",
"head": {"ref": "feat/issue-10-x"},
"merged_at": None,
},
{
"number": 11,
"title": "merged",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
},
],
open_prs=[],
remote_branch_exists={"feat/issue-11-x": True},
head_on_master={11: True},
delete_capability_allowed=False,
)
self.assertEqual(report["merged_pr_count"], 1)
entry = report["entries"][0]
self.assertEqual(entry["pr_number"], 11)
self.assertEqual(entry["issue_number"], 11)
self.assertFalse(entry["remote_branch"]["safe_to_delete_remote"])
self.assertIn("delete_branch capability", entry["remote_branch"]["block_reasons"][0])
def test_has_active_issue_lock_reads_lock_file(self):
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
handle.write('{"branch_name": "feat/issue-9-example"}')
lock_path = handle.name
try:
self.assertTrue(
mcr.has_active_issue_lock("feat/issue-9-example", lock_path=lock_path)
)
self.assertFalse(
mcr.has_active_issue_lock("feat/other-branch", lock_path=lock_path)
)
finally:
os.remove(lock_path)
if __name__ == "__main__":
unittest.main()