feat: allow reconciler profile to run merged cleanup directly (Closes #523)

This commit is contained in:
2026-07-08 22:49:19 -04:00
parent 9a2e585a9e
commit 32fc1719aa
4 changed files with 117 additions and 4 deletions
+2 -2
View File
@@ -106,11 +106,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
},
"reconcile_merged_cleanups": {
"permission": "gitea.read",
"role": "author",
"role": "reconciler",
},
"reconciliation_cleanup": {
"permission": "gitea.branch.delete",
"role": "author",
"role": "reconciler",
},
"work_issue": {
"permission": "gitea.pr.create",
+1 -1
View File
@@ -284,7 +284,7 @@ class TestTaskCapabilityMap(unittest.TestCase):
required_permission("reconciliation_cleanup"),
"gitea.branch.delete",
)
self.assertEqual(required_role("reconciliation_cleanup"), "author")
self.assertEqual(required_role("reconciliation_cleanup"), "reconciler")
if __name__ == "__main__":
@@ -0,0 +1,112 @@
"""Integration tests for reconciler merged cleanup (#523)."""
import os
import sys
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import task_capability_map
from audit_reconciliation_mode import clear_phase
RECONCILER_PROFILE = {
"profile_name": "prgs-reconciler",
"context": "prgs",
"role": "reconciler",
"username": "sysadmin",
"base_url": "https://gitea.prgs.cc",
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.issue.comment"],
"forbidden_operations": [],
}
class TestReconcilerCleanupIntegration(unittest.TestCase):
def setUp(self):
clear_phase()
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_resolved_role = None
mcp_server._preflight_resolved_task = None
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_whoami_violation_files = []
mcp_server._preflight_capability_violation_files = []
self.mock_api = patch("gitea_auth.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value="token test"
).start()
def tearDown(self):
patch.stopall()
clear_phase()
mcp_server._IDENTITY_CACHE.clear()
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True)
@patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_reconciler_can_run_reconcile_merged_cleanups_directly(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups")
# Mock closed PRs and open PRs returned by API
self.mock_api.side_effect = [
[ # closed pulls
{
"number": 100,
"title": "merged feature",
"body": "Closes #100",
"merged": True,
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "deadbeef",
"head": {"ref": "feat/issue-100", "sha": "cafebabe"},
}
],
[], # open pulls
]
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 = mcp_server.gitea_reconcile_merged_cleanups(
dry_run=True,
remote="prgs",
)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
self.assertEqual(result["entries"][0]["issue_number"], 100)
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True)
@patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_reconciler_role_bypasses_branches_only_mutation_guard(self, _profile):
# We simulate verify_preflight_purity under reconciler role.
# It should succeed without raising RuntimeError even if CWD/workspace is the process root.
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="reconciler", resolved_task="reconcile_merged_cleanups")
# Calling verify_preflight_purity directly
try:
mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups")
except RuntimeError as e:
self.fail(f"verify_preflight_purity raised RuntimeError unexpectedly: {e}")
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-author", "GITEA_TEST_PORCELAIN": ""}, clear=True)
@patch("mcp_server.get_profile", return_value={
"profile_name": "prgs-author",
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.create", "gitea.branch.push", "gitea.repo.commit"],
"forbidden_operations": [],
})
def test_author_role_remains_blocked_on_root_checkout(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author", resolved_task="reconcile_merged_cleanups")
with patch("mcp_server.PROJECT_ROOT", "/Users/jasonwalker/Development/Gitea-Tools"):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(remote="prgs", task="reconcile_merged_cleanups")
self.assertIn("author mutation blocked: workspace is the stable control checkout", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -11,7 +11,8 @@ from urllib.parse import urlparse
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
from issue_claim_heartbeat import build_claim_inventory
from merged_cleanup_reconcile import ISSUE_LOCK_FILE, read_issue_lock
from merged_cleanup_reconcile import read_issue_lock
from issue_lock_provenance import ISSUE_LOCK_FILE
from webui.project_registry import ProjectRecord, load_registry
from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs