Provide a narrow phase-scoped bootstrap so gitea_create_issue can run from a clean canonical control checkout when no issue number—and therefore no issue-backed worktree—can exist yet. Dirty roots, non-base branches, base races, foreign workspaces, and all post-creation author mutations remain fail-closed under the ordinary branches-only guard. Closes #749.
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""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-<N>-*", 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 <N>.
|
|
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-<N>" 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-<N> worktree.
|
|
next_a = res.get("exact_next_action") or ""
|
|
if next_a:
|
|
self.assertNotIn("issue-<N>-*", 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()
|