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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user