diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index e7551db..810e546 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -1282,12 +1282,18 @@ def _verify_role_mutation_workspace( worktree_path: str | None = None, worktree: str | None = None, task: str | None = None, + org: str | None = None, + repo: str | None = None, ) -> str: """Bind reviewer/merger mutations to the active namespace workspace (#510). #683: must NOT early-return solely because pytest/unittest is loaded. Production workspace binding always runs; test isolation uses explicit env fixtures / force-on flags, never a production short-circuit here. + + org/repo are forwarded into anti-stomp (#604) so callers that pass explicit + repository targets (e.g. prgs + Gitea-Tools when REMOTES defaults to + Timesheet) do not fail closed with wrong_repo after a successful resolve. """ # Check running runtimes to prevent stale mutations @@ -1350,7 +1356,13 @@ def _verify_role_mutation_workspace( ) ) resolved = assessment["mutation_workspace"] - verify_preflight_purity(remote, worktree_path=resolved, task=task) + verify_preflight_purity( + remote, + worktree_path=resolved, + task=task, + org=org, + repo=repo, + ) return resolved @@ -4521,7 +4533,11 @@ def _evaluate_pr_review_submission( ) -> dict: """Shared gate chain for live submit and dry-run review tools.""" _verify_role_mutation_workspace( - remote, worktree_path=worktree_path, task="review_pr" + remote, + worktree_path=worktree_path, + task="review_pr", + org=org, + repo=repo, ) action = (action or "").strip().lower() workflow_blockers = _review_workflow_load_gate_reasons() if live else [] @@ -7619,7 +7635,11 @@ def gitea_merge_pr( available. Never secrets. """ _verify_role_mutation_workspace( - remote, worktree_path=worktree_path, task="merge_pr" + remote, + worktree_path=worktree_path, + task="merge_pr", + org=org, + repo=repo, ) allowed = get_profile()["allowed_operations"] forbidden = get_profile()["forbidden_operations"] @@ -10652,8 +10672,14 @@ def gitea_acquire_reviewer_pr_lease( # task=acquire_reviewer_pr_lease so verify_preflight_purity runs shared #604 # anti-stomp for the declared lease-acquire mutation inventory entry. + # Forward explicit org/repo so anti-stomp does not default bare remote=prgs + # to Timesheet when the local git remote is Gitea-Tools (#604 wrong_repo). _verify_role_mutation_workspace( - remote, worktree=worktree, task="acquire_reviewer_pr_lease" + remote, + worktree=worktree, + task="acquire_reviewer_pr_lease", + org=org, + repo=repo, ) h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) @@ -10794,7 +10820,11 @@ def gitea_acquire_merger_pr_lease( try: _verify_role_mutation_workspace( - remote, worktree=worktree, task="acquire_merger_pr_lease" + remote, + worktree=worktree, + task="acquire_merger_pr_lease", + org=org, + repo=repo, ) except RuntimeError as e: return { @@ -10987,7 +11017,11 @@ def gitea_adopt_merger_pr_lease( # task=adopt_merger_pr_lease so verify_preflight_purity runs shared #604 anti-stomp. _verify_role_mutation_workspace( - remote, worktree=worktree, task="adopt_merger_pr_lease" + remote, + worktree=worktree, + task="adopt_merger_pr_lease", + org=org, + repo=repo, ) h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) @@ -11709,7 +11743,11 @@ def gitea_release_reviewer_pr_lease( dict reporting release status. """ _verify_role_mutation_workspace( - remote, worktree=worktree, task="review_pr" + remote, + worktree=worktree, + task="review_pr", + org=org, + repo=repo, ) h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) diff --git a/tests/test_merger_lease_acquire.py b/tests/test_merger_lease_acquire.py index 88fa94b..77bc118 100644 --- a/tests/test_merger_lease_acquire.py +++ b/tests/test_merger_lease_acquire.py @@ -145,6 +145,37 @@ class TestMergerLeaseAcquireTool(unittest.TestCase): self.assertIn("Branches-only mutation guard", res["reasons"][0]) self.assertIsNone(leases._SESSION_LEASE) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._auth", return_value="token pass") + @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")) + @patch("gitea_mcp_server.get_profile") + @patch("gitea_mcp_server._fetch_pr_comments", return_value=[]) + @patch("gitea_mcp_server._verify_role_mutation_workspace") + def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify( + self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api + ): + """Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet).""" + mock_profile.return_value = self._merger_profile() + mock_api.side_effect = [ + {"state": "open", "head": {"sha": LIVE_HEAD}}, + {"id": 789}, + ] + with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}): + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + session_id="merger-session", + candidate_head=HEAD, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(res["success"], res) + kwargs = mock_verify.call_args.kwargs + self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting") + self.assertEqual(kwargs.get("repo"), "Gitea-Tools") + self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease") + @patch("gitea_mcp_server.get_profile") def test_acquire_merger_requires_candidate_head(self, mock_profile): mock_profile.return_value = self._merger_profile()