diff --git a/task_capability_map.py b/task_capability_map.py index c86ce2a..8d3daf2 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -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", diff --git a/tests/test_audit_reconciliation_mode.py b/tests/test_audit_reconciliation_mode.py index a117fab..e429ddf 100644 --- a/tests/test_audit_reconciliation_mode.py +++ b/tests/test_audit_reconciliation_mode.py @@ -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__": diff --git a/tests/test_reconciler_cleanup_integration.py b/tests/test_reconciler_cleanup_integration.py new file mode 100644 index 0000000..7b3ef4a --- /dev/null +++ b/tests/test_reconciler_cleanup_integration.py @@ -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() diff --git a/webui/lease_loader.py b/webui/lease_loader.py index 717feef..548aec3 100644 --- a/webui/lease_loader.py +++ b/webui/lease_loader.py @@ -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