diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 84904f7..cc1cbcf 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -417,8 +417,13 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None): def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None: - """#274: author mutations must run from a branches/ session worktree.""" - if _preflight_resolved_role == "reviewer": + """#274: author file/branch mutations must run from a branches/ worktree. + + Reviewer and reconciler roles are exempt: reconciler ``close_pr`` is a + Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE`` + (#468). + """ + if _preflight_resolved_role in ("reviewer", "reconciler"): return ctx = _resolve_author_mutation_context(worktree_path) workspace = ctx["workspace_path"] diff --git a/tests/test_reconciler_close_workspace_guard.py b/tests/test_reconciler_close_workspace_guard.py new file mode 100644 index 0000000..54a24e4 --- /dev/null +++ b/tests/test_reconciler_close_workspace_guard.py @@ -0,0 +1,89 @@ +"""Reconciler close_pr must not require author branches/ worktree (#468).""" +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import gitea_mcp_server as srv + +FAKE_AUTH = "token test" +CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3]) + +RECONCILER_PROFILE = { + "profile_name": "prgs-reconciler", + "allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "audit_label": "prgs-reconciler", +} + + +class TestReconcilerCloseWorkspaceGuard(unittest.TestCase): + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_whoami_violation = False + srv._preflight_capability_violation = False + self._orig_in_test = srv._preflight_in_test_mode + srv._preflight_in_test_mode = lambda: False + + def tearDown(self): + srv._preflight_in_test_mode = self._orig_in_test + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server._namespace_mutation_block", return_value=None) + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + @patch("gitea_mcp_server.api_request") + def test_reconciler_close_pr_from_control_checkout_succeeds( + self, mock_api, _profile, _ns, _auth + ): + srv._preflight_resolved_role = "reconciler" + mock_api.return_value = { + "number": 414, + "title": "old", + "body": "", + "state": "closed", + "html_url": "https://gitea.example.com/pulls/414", + } + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GITEA_AUTHOR_WORKTREE", None) + os.environ.pop("GITEA_ACTIVE_WORKTREE", None) + result = srv.gitea_edit_pr(414, state="closed", remote="prgs") + self.assertTrue(result["success"]) + self.assertEqual(result["state"], "closed") + mock_api.assert_called_once() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server._profile_permission_block", return_value=None) + @patch("gitea_mcp_server._namespace_mutation_block", return_value=None) + @patch( + "gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, []), + ) + @patch("gitea_mcp_server.api_get_all", return_value=[]) + @patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={"current_branch": "master"}, + ) + def test_author_create_issue_still_blocked_on_control_checkout( + self, _git, _get_all, _role, _ns, _prof, _auth + ): + srv._preflight_resolved_role = "author" + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue(title="Test", body="body") + self.assertIn("stable control checkout", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file