"""Regression tests for create_issue bootstrap (#749). TDD: these tests define the sanctioned first-mutation path for ``gitea_create_issue`` from a clean canonical control checkout, and prove the exemption cannot widen to dirty roots, foreign clones, arbitrary ``branches/`` directories, or post-creation author mutations. """ from __future__ import annotations import os import sys import unittest from pathlib import Path from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import create_issue_bootstrap as cib # noqa: E402 import gitea_mcp_server as srv # noqa: E402 import workflow_scope_guard as wsg # noqa: E402 FAKE_AUTH = {"Authorization": "token test-token"} MASTER_SHA = "a" * 40 STALE_SHA = "b" * 40 current_file_path = Path(__file__).resolve() if "branches" in current_file_path.parts: CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3]) else: CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1]) class TestCreateIssueBootstrapAssessor(unittest.TestCase): ROOT = "/repo/Gitea-Tools" def test_non_create_issue_task_not_applicable(self): res = cib.assess_create_issue_bootstrap( workspace_path=self.ROOT, canonical_repo_root=self.ROOT, current_branch="master", head_sha=MASTER_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="lock_issue", ) self.assertTrue(res["not_applicable"]) self.assertFalse(res["allowed"]) self.assertFalse(res["block"]) def test_branches_worktree_not_applicable(self): res = cib.assess_create_issue_bootstrap( workspace_path=f"{self.ROOT}/branches/issue-1-x", canonical_repo_root=self.ROOT, current_branch="fix/issue-1-x", task="create_issue", ) self.assertTrue(res["not_applicable"]) self.assertFalse(res["allowed"]) def test_clean_control_checkout_allowed(self): res = cib.assess_create_issue_bootstrap( workspace_path=self.ROOT, canonical_repo_root=self.ROOT, current_branch="master", head_sha=MASTER_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="create_issue", ) self.assertFalse(res["not_applicable"]) self.assertTrue(res["allowed"]) self.assertFalse(res["block"]) self.assertEqual(res["bootstrap_path"], "clean_canonical_control_checkout") # Post-create next action must name issue-backed worktree after N exists. self.assertIn("branches/issue--*", res["exact_next_action"]) def test_tool_alias_gitea_create_issue_allowed(self): res = cib.assess_create_issue_bootstrap( workspace_path=self.ROOT, canonical_repo_root=self.ROOT, current_branch="main", head_sha=MASTER_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="gitea_create_issue", ) self.assertTrue(res["allowed"]) def test_dirty_control_checkout_blocked(self): res = cib.assess_create_issue_bootstrap( workspace_path=self.ROOT, canonical_repo_root=self.ROOT, current_branch="master", head_sha=MASTER_SHA, porcelain_status=" M gitea_mcp_server.py\n", remote_master_sha=MASTER_SHA, task="create_issue", ) self.assertTrue(res["block"]) self.assertFalse(res["allowed"]) self.assertTrue(any("tracked local edits" in r for r in res["reasons"])) # Pre-issue phase: next action must be satisfiable without inventing . next_a = res["exact_next_action"] or "" self.assertIn("clean accepted base branch", next_a) self.assertIn("before the issue exists", next_a) # Must not prescribe "bind branches/issue-" as the recovery step. self.assertNotIn("Bind an issue-backed worktree", next_a) def test_stale_base_blocked(self): res = cib.assess_create_issue_bootstrap( workspace_path=self.ROOT, canonical_repo_root=self.ROOT, current_branch="master", head_sha=STALE_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="create_issue", ) self.assertTrue(res["block"]) self.assertTrue(any("live master" in r for r in res["reasons"])) def test_non_base_branch_blocked(self): res = cib.assess_create_issue_bootstrap( workspace_path=self.ROOT, canonical_repo_root=self.ROOT, current_branch="feat/something", head_sha=MASTER_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="create_issue", ) self.assertTrue(res["block"]) def test_detached_head_blocked(self): res = cib.assess_create_issue_bootstrap( workspace_path=self.ROOT, canonical_repo_root=self.ROOT, current_branch="", head_sha=MASTER_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="create_issue", ) self.assertTrue(res["block"]) self.assertTrue(any("detached" in r for r in res["reasons"])) def test_foreign_workspace_blocked(self): res = cib.assess_create_issue_bootstrap( workspace_path="/other/clone", canonical_repo_root=self.ROOT, current_branch="master", head_sha=MASTER_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="create_issue", ) self.assertTrue(res["block"]) self.assertTrue(any("canonical control checkout" in r for r in res["reasons"])) class TestCreateIssueBootstrapIntegration(unittest.TestCase): def setUp(self): srv._preflight_whoami_called = True srv._preflight_capability_called = True srv._preflight_resolved_role = "author" srv._preflight_resolved_task = "create_issue" 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 srv._preflight_resolved_task = None def _git_state(self, branch="master", head=MASTER_SHA, porcelain=""): return { "current_branch": branch, "head_sha": head, "porcelain_status": porcelain, } @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_request") @patch("gitea_mcp_server.api_get_all", return_value=[]) @patch( "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA, ) def test_clean_control_checkout_create_issue_succeeds( self, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth ): mock_api.return_value = { "number": 99, "html_url": "https://gitea.example.com/issues/99", } with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=self._git_state(), ): with patch( "gitea_mcp_server._get_workspace_porcelain", return_value="" ): with patch( "gitea_mcp_server._enforce_root_checkout_guard" ): # Anti-stomp / master parity: keep gates green. with patch.object( srv, "_run_anti_stomp_preflight", return_value=None, ): res = srv.gitea_create_issue( title="Bootstrap issue from clean control", body="Body text for content gate.", ) self.assertEqual(res.get("number"), 99) 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_request") @patch("gitea_mcp_server.api_get_all", return_value=[]) def test_dirty_control_checkout_create_issue_fails_closed( self, _get_all, mock_api, _role, _ns, _prof, _auth ): with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=self._git_state( porcelain=" M author_mutation_worktree.py\n" ), ): with patch( "gitea_mcp_server._get_workspace_porcelain", return_value=" M author_mutation_worktree.py\n", ): res = srv.gitea_create_issue( title="Should fail on dirty root", body="Body text for content gate.", ) self.assertFalse(res.get("success", True) and res.get("number")) if isinstance(res, dict) and res.get("success") is False: blob = " ".join(res.get("reasons") or []) self.assertTrue( "tracked local edits" in blob or "dirty" in blob.lower() or "control checkout" in blob.lower() or res.get("blocker_kind") ) # Pre-issue phase must not demand issue- worktree. next_a = res.get("exact_next_action") or "" if next_a: self.assertNotIn("issue--*", next_a) mock_api.assert_not_called() @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, []), ) def test_lock_issue_still_requires_branches_worktree(self, _role, _ns, _prof, _auth): """Existing issue-backed mutations receive no exemption (#749 AC3/AC7).""" srv._preflight_resolved_task = "lock_issue" with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): with patch( "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=self._git_state(), ): with patch( "gitea_mcp_server._get_workspace_porcelain", return_value="" ): with patch( "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA, ): with patch.object( srv, "_enforce_root_checkout_guard" ): with self.assertRaises(RuntimeError) as ctx: srv.verify_preflight_purity( remote="prgs", task="lock_issue", ) msg = str(ctx.exception) self.assertTrue( "Branches-only mutation guard" in msg or "stable control checkout" in msg, msg, ) self.assertIn("control checkout", msg) def test_workflow_scope_skips_missing_worktree_for_create_issue_clean_root(self): """#683 root assessor must not block clean-root create_issue bootstrap.""" res = wsg.assess_root_source_mutation( workspace_path=CONTROL_CHECKOUT_ROOT, canonical_repo_root=CONTROL_CHECKOUT_ROOT, porcelain_status="", role_kind="author", mutation_task="create_issue", ) self.assertFalse(res["block"], res) self.assertTrue(res.get("create_issue_bootstrap") or res["proven"]) def test_workflow_scope_still_blocks_clean_root_for_lock_issue(self): res = wsg.assess_root_source_mutation( workspace_path=CONTROL_CHECKOUT_ROOT, canonical_repo_root=CONTROL_CHECKOUT_ROOT, porcelain_status="", role_kind="author", mutation_task="lock_issue", ) self.assertTrue(res["block"]) self.assertEqual(res["blocker_kind"], wsg.BLOCKER_MISSING_WORKTREE) def test_arbitrary_branches_directory_not_bootstrap(self): """#713: mkdir fake under branches/ is not the bootstrap path.""" fake = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "fake-mkdir-only") res = cib.assess_create_issue_bootstrap( workspace_path=fake, canonical_repo_root=CONTROL_CHECKOUT_ROOT, current_branch="master", head_sha=MASTER_SHA, porcelain_status="", remote_master_sha=MASTER_SHA, task="create_issue", ) # Under branches/ → not bootstrap; ordinary membership/registration applies. self.assertTrue(res["not_applicable"]) self.assertFalse(res["allowed"]) class TestCreateIssueCapabilityAgreement(unittest.TestCase): def test_map_and_alias_agree_on_create_issue(self): import task_capability_map as tcm self.assertEqual( tcm.required_permission("create_issue"), "gitea.issue.create", ) # Tool alias must resolve to the same task contract. alias = getattr(tcm, "TOOL_TASK_ALIASES", None) or getattr( tcm, "TASK_ALIASES", None ) if alias is not None: mapped = alias.get("gitea_create_issue") if mapped is not None: self.assertEqual(mapped, "create_issue") if __name__ == "__main__": unittest.main()