fix: control-plane guide org/repo params and local repo alignment (#588)

#530's remote/repo guard blocked mcp_get_control_plane_guide(remote=prgs)
because the bare default resolves to Timesheet while local git is
Gitea-Tools, and remediation told callers to pass org/repo on a tool that
did not accept those parameters.

- Accept optional org/repo on mcp_get_control_plane_guide
- Prefer local git remote org/repo when bare defaults mismatch (read-only guide)
- Keep mutation tools fail-closed on bare mismatch
- Align remediation text with tools that accept org/repo

Closes #588
This commit is contained in:
2026-07-09 13:17:27 -04:00
parent 27d75facd4
commit 57b2023611
4 changed files with 202 additions and 6 deletions
+15
View File
@@ -88,6 +88,21 @@ class TestControlPlaneGuide(GuideTestBase):
blob = " ".join(g["guidance"]).lower()
self.assertIn("forbidden", blob)
self.assertIn("review", blob)
self.assertIn("resolved_repository", g)
@patch("mcp_server.api_request", return_value={"login": "author-bot"})
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_guide_explicit_org_repo_parameters(self, _auth, _api):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
g = mcp_get_control_plane_guide(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertEqual(
g["resolved_repository"],
{"org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"},
)
@patch("mcp_server.api_request", return_value={"login": "reviewer-bot"})
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
+93
View File
@@ -111,6 +111,23 @@ class TestAssessRemoteRepoMatch(unittest.TestCase):
self.assertIn("Gitea-Tools", message)
self.assertIn("org=", message)
self.assertIn("repo=", message)
# Recovery text must name a tool that actually accepts org/repo (#530 follow-up).
self.assertIn("mcp_get_control_plane_guide", message)
self.assertIn("schema does not list", message)
def test_parse_org_repo_from_https_url(self):
parsed = remote_repo_guard.parse_org_repo_from_remote_url(LOCAL_GITEA_TOOLS_URL)
self.assertEqual(parsed, ("Scaled-Tech-Consulting", "Gitea-Tools"))
def test_parse_org_repo_from_ssh_url(self):
parsed = remote_repo_guard.parse_org_repo_from_remote_url(
"[email protected]:Scaled-Tech-Consulting/Gitea-Tools.git"
)
self.assertEqual(parsed, ("Scaled-Tech-Consulting", "Gitea-Tools"))
def test_parse_org_repo_empty(self):
self.assertIsNone(remote_repo_guard.parse_org_repo_from_remote_url(None))
self.assertIsNone(remote_repo_guard.parse_org_repo_from_remote_url(""))
class TestResolveServerWiring(unittest.TestCase):
@@ -173,6 +190,82 @@ class TestResolveServerWiring(unittest.TestCase):
with self.assertRaises(RuntimeError):
self.server.gitea_view_issue(issue_number=1, remote="prgs")
def test_control_plane_guide_accepts_explicit_gitea_tools(self):
"""Guide schema accepts org/repo and resolves Gitea-Tools, not Timesheet."""
self._set_prgs_default_repo("Timesheet")
with mock.patch.object(
self.server, "api_request", return_value={"login": "guide-bot"}
), mock.patch.object(
self.server, "get_auth_header", return_value="Basic dGVzdA=="
), mock.patch.dict(
os.environ,
{
"GITEA_PROFILE_NAME": "author-test",
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.merge",
},
clear=False,
):
guide = self.server.mcp_get_control_plane_guide(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(guide["read_only"])
self.assertEqual(
guide["resolved_repository"],
{"org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"},
)
self.assertNotEqual(guide["resolved_repository"]["repo"], "Timesheet")
def test_control_plane_guide_bare_remote_aligns_to_local_gitea_tools(self):
"""Bare remote=prgs in a Gitea-Tools worktree must not stay on Timesheet."""
self._set_prgs_default_repo("Timesheet")
with mock.patch.object(
self.server, "api_request", return_value={"login": "guide-bot"}
), mock.patch.object(
self.server, "get_auth_header", return_value="Basic dGVzdA=="
), mock.patch.dict(
os.environ,
{
"GITEA_PROFILE_NAME": "author-test",
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.merge",
},
clear=False,
):
guide = self.server.mcp_get_control_plane_guide(remote="prgs")
self.assertEqual(guide["resolved_repository"]["repo"], "Gitea-Tools")
self.assertEqual(
guide["resolved_repository"]["org"], "Scaled-Tech-Consulting"
)
def test_control_plane_guide_wrong_default_fails_closed_without_local(self):
"""Without local remote context, mismatched defaults still fail closed."""
self._set_prgs_default_repo("Timesheet")
with mock.patch.object(
self.server, "_local_git_remote_url", return_value=None
):
# No local URL → preference cannot recover; bare resolve uses Timesheet
# and guard is skipped only because local URL is missing (best-effort).
host, org, repo = self.server._resolve_control_plane_guide_target(
"prgs", None, None, None
)
self.assertEqual(repo, "Timesheet")
def test_control_plane_guide_schema_recovery_matches_parameters(self):
"""Remediation must only recommend parameters the guide actually accepts."""
import inspect
sig = inspect.signature(self.server.mcp_get_control_plane_guide)
self.assertIn("org", sig.parameters)
self.assertIn("repo", sig.parameters)
self.assertIn("remote", sig.parameters)
remediation = remote_repo_guard.REMEDIATION
self.assertIn("mcp_get_control_plane_guide", remediation)
self.assertIn("org=", remediation)
self.assertIn("repo=", remediation)
if __name__ == "__main__":
unittest.main()