fix: resolve conflicts for PR #499
Merge prgs/master into PR branch to restore mergeability. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
from mcp_server import ( # noqa: E402
|
||||
_fetch_pr_lease_comments_safe,
|
||||
gitea_assess_conflict_fix_push,
|
||||
)
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
AUTHOR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "prgs-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.branch.push,gitea.pr.create",
|
||||
}
|
||||
|
||||
HEAD_BEFORE = "dad1dc8d5108ab01ed83065334116d7425a4471c"
|
||||
HEAD_AFTER = "3f3d6cb35d0fe225dcb236247f2a2d0ec193fa35"
|
||||
OPEN_PR = {
|
||||
"number": 508,
|
||||
"state": "open",
|
||||
"head": {"sha": HEAD_AFTER},
|
||||
}
|
||||
|
||||
|
||||
class TestFetchPrLeaseCommentsSafe(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_pr_lookup_404_returns_structured_failure(self, _auth, mock_api):
|
||||
mock_api.side_effect = RuntimeError(
|
||||
'HTTP 404: {"message":"issue does not exist","index":508}'
|
||||
)
|
||||
result = _fetch_pr_lease_comments_safe(
|
||||
508, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["comments"], [])
|
||||
self.assertTrue(result["reasons"])
|
||||
self.assertIn("PR lookup failed", result["reasons"][0])
|
||||
self.assertEqual(result["pr_lookup"], "failed")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_comment_fetch_404_after_pr_lookup(self, _auth, mock_api):
|
||||
def _api(method, url, auth, *args, **kwargs):
|
||||
if "/pulls/" in url:
|
||||
return OPEN_PR
|
||||
raise RuntimeError(
|
||||
'HTTP 404: {"message":"issue does not exist","index":508}'
|
||||
)
|
||||
|
||||
mock_api.side_effect = _api
|
||||
result = _fetch_pr_lease_comments_safe(
|
||||
508, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("comment fetch failed", result["reasons"][0].lower())
|
||||
self.assertEqual(result["pr_lookup"], "ok")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_success_returns_comments_and_head_sha(self, _auth, mock_api):
|
||||
comment = {"id": 1, "body": "<!-- mcp-conflict-fix-lease:v1 -->"}
|
||||
|
||||
def _api(method, url, auth, *args, **kwargs):
|
||||
if "/pulls/" in url:
|
||||
return OPEN_PR
|
||||
return [comment]
|
||||
|
||||
mock_api.side_effect = _api
|
||||
result = _fetch_pr_lease_comments_safe(
|
||||
508, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["comments"], [comment])
|
||||
self.assertEqual(result["head_sha"], OPEN_PR["head"]["sha"])
|
||||
|
||||
|
||||
class TestAssessConflictFixPushTool(unittest.TestCase):
|
||||
@patch("mcp_server._fetch_pr_lease_comments_safe")
|
||||
@patch("mcp_server._profile_operation_gate", return_value=None)
|
||||
def test_returns_structured_block_when_pr_lookup_fails(
|
||||
self, _gate, mock_fetch,
|
||||
):
|
||||
mock_fetch.return_value = {
|
||||
"success": False,
|
||||
"comments": [],
|
||||
"reasons": ["PR lookup failed"],
|
||||
"pr_lookup": "failed",
|
||||
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"remote": "prgs",
|
||||
"pr_number": 508,
|
||||
}
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_assess_conflict_fix_push(
|
||||
pr_number=508,
|
||||
branch_head_before=HEAD_BEFORE,
|
||||
branch_head_after=HEAD_AFTER,
|
||||
worktree_path="/proj/branches/fix-pr508",
|
||||
push_cwd="/proj/branches/fix-pr508",
|
||||
is_fast_forward=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(result.get("assessment_failed"))
|
||||
self.assertEqual(result["pr_lookup"], "failed")
|
||||
|
||||
@patch("mcp_server._fetch_pr_lease_comments_safe")
|
||||
@patch("mcp_server._profile_operation_gate", return_value=None)
|
||||
def test_valid_push_assessment_when_pr_and_comments_resolve(
|
||||
self, _gate, mock_fetch,
|
||||
):
|
||||
mock_fetch.return_value = {
|
||||
"success": True,
|
||||
"comments": [],
|
||||
"reasons": [],
|
||||
"pr_lookup": "ok",
|
||||
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
|
||||
"remote": "prgs",
|
||||
"pr_number": 508,
|
||||
"head_sha": HEAD_AFTER,
|
||||
}
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_assess_conflict_fix_push(
|
||||
pr_number=508,
|
||||
branch_head_before=HEAD_BEFORE,
|
||||
branch_head_after=HEAD_AFTER,
|
||||
worktree_path="/proj/branches/fix-pr508",
|
||||
push_cwd="/proj/branches/fix-pr508",
|
||||
is_fast_forward=True,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(result["push_allowed"])
|
||||
self.assertEqual(result.get("live_pr_head_sha"), HEAD_AFTER)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -294,9 +294,13 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
super().setUp()
|
||||
from tests.test_mcp_server import _init_reviewer_session, _install_owned_reviewer_lease
|
||||
import reviewer_pr_lease
|
||||
import review_workflow_boundary
|
||||
import review_workflow_load
|
||||
|
||||
# Session init clears any prior session lease (#407) and loads workflow (#389).
|
||||
_init_reviewer_session("prgs")
|
||||
self.addCleanup(review_workflow_load.clear_review_workflow_load)
|
||||
self.addCleanup(review_workflow_boundary.clear_pre_review_commands)
|
||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
||||
self._lease_patch.start()
|
||||
self._auth_identity_patch = patch(
|
||||
|
||||
@@ -38,8 +38,18 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
@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.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
||||
def test_create_issue_stable_checkout_rejected(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": "a" * 40,
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
def test_create_issue_stable_checkout_rejected(
|
||||
self, _git, _remote_sha, _get_all, mock_api, _role, _ns, _prof, _auth,
|
||||
):
|
||||
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
|
||||
# path is the stable control checkout (not under branches/), mutation must fail.
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Regression tests for #540: comment_issue preflight must not poison the
|
||||
actual reconciler role for #274 branch-only / #475 root-checkout exemptions.
|
||||
|
||||
`resolve_task_capability("comment_issue")` stamps
|
||||
``_preflight_resolved_role = "author"`` because ``comment_issue`` has
|
||||
``required_role_kind = author``. Before the fix, ``_effective_workspace_role``
|
||||
preferred that stamp and a genuine ``prgs-reconciler`` session was treated as an
|
||||
author inside the #274 branch-only mutation guard and the #475 root checkout
|
||||
guard, blocking ``gitea_create_issue_comment`` from the control checkout.
|
||||
|
||||
The fix keys the role exemptions off the *actual profile role* as well, so the
|
||||
exemption survives the poisoned task role while author profiles stay blocked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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 # noqa: E402
|
||||
import root_checkout_guard as rcg # noqa: E402
|
||||
|
||||
FAKE_AUTH = "token test"
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
MASTER_SHA = "a" * 40
|
||||
OTHER_SHA = "b" * 40
|
||||
|
||||
RECONCILER_PROFILE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
AUTHOR_PROFILE = {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.create",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
"audit_label": "prgs-author",
|
||||
}
|
||||
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
"audit_label": "prgs-reviewer",
|
||||
}
|
||||
|
||||
|
||||
class TestActualProfileRole(unittest.TestCase):
|
||||
"""`_actual_profile_role` ignores the poisoned preflight task role (#540)."""
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_resolved_role = None
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
def test_reconciler_profile_role_survives_author_task_stamp(self, _profile):
|
||||
srv._preflight_resolved_role = "author" # comment_issue poison
|
||||
self.assertEqual(srv._actual_profile_role(), "reconciler")
|
||||
# _effective_workspace_role is still poisoned to author (unchanged #510)...
|
||||
self.assertEqual(srv._effective_workspace_role(), "author")
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
|
||||
def test_author_profile_role_is_author(self, _profile):
|
||||
srv._preflight_resolved_role = "author"
|
||||
self.assertEqual(srv._actual_profile_role(), "author")
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=REVIEWER_PROFILE)
|
||||
def test_reviewer_profile_role_is_reviewer(self, _profile):
|
||||
srv._preflight_resolved_role = "author"
|
||||
self.assertEqual(srv._actual_profile_role(), "reviewer")
|
||||
|
||||
|
||||
class TestBranchesOnlyExemptionRealRole(unittest.TestCase):
|
||||
"""#274 branches-only exemption keys off the actual profile role (#540)."""
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_resolved_role = None
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
def test_reconciler_exempt_despite_author_task_stamp(self, _profile):
|
||||
srv._preflight_resolved_role = "author" # poison
|
||||
# Must return without raising and without resolving an author worktree.
|
||||
with patch("gitea_mcp_server._resolve_namespace_mutation_context") as ctx:
|
||||
srv._enforce_branches_only_author_mutation()
|
||||
ctx.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
|
||||
def test_author_not_exempt(self, _profile):
|
||||
srv._preflight_resolved_role = "author"
|
||||
sentinel = RuntimeError("author-mutation-guard-reached")
|
||||
|
||||
def _blow_up(*_a, **_k):
|
||||
raise sentinel
|
||||
|
||||
# Author is not exempt: the guard proceeds to resolve/assess the
|
||||
# workspace (proven by reaching the patched context resolver).
|
||||
with patch(
|
||||
"gitea_mcp_server._resolve_namespace_mutation_context",
|
||||
side_effect=_blow_up,
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
srv._enforce_branches_only_author_mutation()
|
||||
self.assertIs(raised.exception, sentinel)
|
||||
|
||||
|
||||
class TestRootCheckoutGuardRealRole(unittest.TestCase):
|
||||
"""#475 root guard honours the actual profile role too (#540)."""
|
||||
|
||||
def _assess(self, **kwargs):
|
||||
defaults = {
|
||||
"workspace_path": CONTROL_CHECKOUT_ROOT,
|
||||
"canonical_repo_root": CONTROL_CHECKOUT_ROOT,
|
||||
"current_branch": "feat/some-branch",
|
||||
"head_sha": OTHER_SHA,
|
||||
"porcelain_status": " M gitea_mcp_server.py\n",
|
||||
"remote_master_sha": MASTER_SHA,
|
||||
"resolved_role": "author", # poisoned task role
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return rcg.assess_root_checkout_guard(**defaults)
|
||||
|
||||
def test_actual_reconciler_exempt_despite_poisoned_resolved_author(self):
|
||||
result = self._assess(actual_role="reconciler")
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_actual_author_still_blocked_on_contaminated_root(self):
|
||||
result = self._assess(actual_role="author")
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_merger_strictness_stays_keyed_on_resolved_task_role(self):
|
||||
# When the merge task resolves the merger role, the branches/ auto
|
||||
# exemption is denied and a clean control checkout is required.
|
||||
blocked = self._assess(
|
||||
workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1",
|
||||
current_branch="review-pr-1",
|
||||
porcelain_status="",
|
||||
head_sha=OTHER_SHA,
|
||||
resolved_role="merger",
|
||||
)
|
||||
self.assertTrue(blocked["block"])
|
||||
|
||||
def test_actual_merger_does_not_over_tighten_non_merge_task(self):
|
||||
# A merger profile whose current task did NOT resolve to merger keeps
|
||||
# the branches/ workspace exemption (regression guard for #540): the
|
||||
# actual_role signal must not force merger strictness here.
|
||||
result = self._assess(
|
||||
workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1",
|
||||
current_branch="review-pr-1",
|
||||
porcelain_status="",
|
||||
head_sha=OTHER_SHA,
|
||||
resolved_role="reviewer",
|
||||
actual_role="merger",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_backward_compatible_without_actual_role(self):
|
||||
# No actual_role supplied: behaviour falls back to resolved_role.
|
||||
result = self._assess(resolved_role="reconciler")
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase):
|
||||
"""Integration: reconciler comment survives the poisoned author task role."""
|
||||
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
srv._preflight_capability_baseline_porcelain = ""
|
||||
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_role = None
|
||||
|
||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch(
|
||||
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||
return_value=MASTER_SHA,
|
||||
)
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": MASTER_SHA,
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
def test_reconciler_comment_from_control_checkout_succeeds(
|
||||
self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain
|
||||
):
|
||||
srv._preflight_resolved_role = "author" # comment_issue poison
|
||||
mock_api.return_value = {"id": 9001, "html_url": "https://x/y"}
|
||||
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)
|
||||
os.environ.pop("GITEA_RECONCILER_WORKTREE", None)
|
||||
result = srv.gitea_create_issue_comment(
|
||||
515, "canonical reconciler audit", remote="prgs"
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["comment_id"], 9001)
|
||||
mock_api.assert_called_once()
|
||||
|
||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch(
|
||||
"gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha",
|
||||
return_value=MASTER_SHA,
|
||||
)
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": MASTER_SHA,
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
@patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE)
|
||||
def test_author_comment_from_control_checkout_blocked(
|
||||
self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain
|
||||
):
|
||||
srv._preflight_resolved_role = "author"
|
||||
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)
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.gitea_create_issue_comment(
|
||||
515, "author note", remote="prgs"
|
||||
)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,257 @@
|
||||
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 gitea_mcp_server as srv
|
||||
|
||||
|
||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
|
||||
|
||||
class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
||||
AUTHOR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
srv._preflight_resolved_task = "comment_issue"
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
srv._preflight_reviewer_violation_files = []
|
||||
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
self.addCleanup(self._restore_preflight_mode)
|
||||
|
||||
def _restore_preflight_mode(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
|
||||
def _git_state(self, valid_worktree: str):
|
||||
def side_effect(path):
|
||||
real = os.path.realpath(path)
|
||||
if real == os.path.realpath(CONTROL_CHECKOUT_ROOT):
|
||||
return {
|
||||
"current_branch": "master",
|
||||
"head_sha": "a" * 40,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
if real == os.path.realpath(valid_worktree):
|
||||
return {
|
||||
"current_branch": "fix/issue-560-issue-comment-worktree-path",
|
||||
"head_sha": "b" * 40,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
return {
|
||||
"current_branch": "other",
|
||||
"head_sha": "c" * 40,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
|
||||
return side_effect
|
||||
|
||||
def _subprocess(self, valid_worktree: str, outside_worktree: str | None = None):
|
||||
def side_effect(cmd, *args, **kwargs):
|
||||
result = MagicMock(returncode=0, stdout="")
|
||||
cwd = ""
|
||||
if isinstance(cmd, list) and "-C" in cmd:
|
||||
cwd = os.path.realpath(cmd[cmd.index("-C") + 1])
|
||||
|
||||
if isinstance(cmd, list) and "--show-toplevel" in cmd:
|
||||
if cwd == os.path.realpath(valid_worktree):
|
||||
result.stdout = f"{valid_worktree}\n"
|
||||
elif outside_worktree and cwd == os.path.realpath(outside_worktree):
|
||||
result.stdout = f"{outside_worktree}\n"
|
||||
else:
|
||||
result.stdout = f"{CONTROL_CHECKOUT_ROOT}\n"
|
||||
return result
|
||||
|
||||
if isinstance(cmd, list) and "--git-common-dir" in cmd:
|
||||
if cwd == os.path.realpath(valid_worktree):
|
||||
result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
||||
elif outside_worktree and cwd == os.path.realpath(outside_worktree):
|
||||
result.stdout = "/tmp/other-repo/.git\n"
|
||||
else:
|
||||
result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
return side_effect
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
def test_comment_rejects_control_checkout_without_worktree_path(
|
||||
self, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
mock_api.return_value = {"id": 42}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_comment_accepts_valid_branches_worktree_path_with_explicit_repo(
|
||||
self, _exists, _isdir, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
mock_api.return_value = {"id": 43}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server.subprocess.run",
|
||||
side_effect=self._subprocess(valid_worktree),
|
||||
):
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path=valid_worktree,
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["performed"])
|
||||
self.assertEqual(result["comment_id"], 43)
|
||||
method, url, _auth_arg, payload = mock_api.call_args[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url)
|
||||
self.assertEqual(payload, {"body": "canonical evidence"})
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_comment_rejects_outside_repo_worktree_path(
|
||||
self, _exists, _isdir, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
outside_worktree = "/tmp/not-gitea-tools-worktree"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server.subprocess.run",
|
||||
side_effect=self._subprocess(valid_worktree, outside_worktree),
|
||||
):
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
worktree_path=outside_worktree,
|
||||
)
|
||||
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_comment_still_requires_capability_preflight(self, mock_api, _auth):
|
||||
srv._preflight_capability_called = False
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
worktree_path=os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
),
|
||||
)
|
||||
self.assertIn("Task capability", str(ctx.exception))
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
def test_comment_still_requires_issue_comment_permission(
|
||||
self, _exists, _isdir, _remote_sha, mock_api, _auth
|
||||
):
|
||||
valid_worktree = os.path.join(
|
||||
CONTROL_CHECKOUT_ROOT,
|
||||
"branches",
|
||||
"issue-560-issue-comment-worktree-path",
|
||||
)
|
||||
denied_env = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment",
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
side_effect=self._git_state(valid_worktree),
|
||||
):
|
||||
with patch(
|
||||
"gitea_mcp_server.subprocess.run",
|
||||
side_effect=self._subprocess(valid_worktree),
|
||||
):
|
||||
with patch.dict(os.environ, denied_env, clear=True):
|
||||
result = srv.gitea_create_issue_comment(
|
||||
issue_number=557,
|
||||
body="canonical evidence",
|
||||
remote="prgs",
|
||||
worktree_path=valid_worktree,
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertIn("permission_report", result)
|
||||
self.assertEqual(
|
||||
result["permission_report"]["missing_permission"],
|
||||
"gitea.issue.comment",
|
||||
)
|
||||
mock_api.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Hermetic tests for repository-root MCP operator menu (#478)."""
|
||||
import os
|
||||
import stat
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "mcp-menu.sh"
|
||||
DOCS = REPO_ROOT / "docs" / "mcp-menu.md"
|
||||
|
||||
REQUIRED_MENU_LABELS = (
|
||||
"Project status / root checkout health",
|
||||
"Author workflow prompts",
|
||||
"Reviewer workflow prompts",
|
||||
"Merger workflow prompts",
|
||||
"Reconciler workflow prompts",
|
||||
"Onboarding new project to this MCP workflow",
|
||||
"Proxmox deployment menu placeholder",
|
||||
"Create Proxmox LXC placeholder",
|
||||
"Run tests",
|
||||
"Exit",
|
||||
)
|
||||
|
||||
DANGEROUS_PATTERNS = (
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"--force-with-lease",
|
||||
"delete-branch",
|
||||
"gitea_delete_branch",
|
||||
"issue-locks",
|
||||
"issue_lock_store",
|
||||
"curl ",
|
||||
"wget ",
|
||||
)
|
||||
|
||||
|
||||
class TestMcpMenuScript(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertTrue(SCRIPT.is_file(), "mcp-menu.sh must exist at repo root")
|
||||
self.content = SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
def test_script_exists_at_repo_root(self):
|
||||
self.assertEqual(SCRIPT.name, "mcp-menu.sh")
|
||||
self.assertEqual(SCRIPT.parent, REPO_ROOT)
|
||||
|
||||
def test_executable_bit_is_set(self):
|
||||
mode = SCRIPT.stat().st_mode
|
||||
self.assertTrue(mode & stat.S_IXUSR, "mcp-menu.sh must be executable by owner")
|
||||
|
||||
def test_shebang(self):
|
||||
first_line = self.content.splitlines()[0]
|
||||
self.assertEqual(first_line, "#!/usr/bin/env bash")
|
||||
|
||||
def test_uses_set_euo_pipefail(self):
|
||||
self.assertIn("set -euo pipefail", self.content)
|
||||
|
||||
def test_no_dangerous_commands(self):
|
||||
lowered = self.content.lower()
|
||||
for pattern in DANGEROUS_PATTERNS:
|
||||
with self.subTest(pattern=pattern):
|
||||
self.assertNotIn(pattern.lower(), lowered)
|
||||
|
||||
def test_no_branch_deletion_verbs(self):
|
||||
for token in ("git branch -D", "git branch -d", "push :refs"):
|
||||
with self.subTest(token=token):
|
||||
self.assertNotIn(token, self.content)
|
||||
|
||||
def test_contains_required_menu_labels(self):
|
||||
for label in REQUIRED_MENU_LABELS:
|
||||
with self.subTest(label=label):
|
||||
self.assertIn(label, self.content)
|
||||
|
||||
def test_run_tests_prefers_run_tests_sh(self):
|
||||
self.assertIn('run-tests.sh', self.content)
|
||||
run_tests_idx = self.content.index("run_tests()")
|
||||
run_tests_body = self.content[run_tests_idx : run_tests_idx + 800]
|
||||
pytest_idx = run_tests_body.find("pytest")
|
||||
run_tests_sh_idx = run_tests_body.find("run-tests.sh")
|
||||
self.assertGreater(pytest_idx, 0)
|
||||
self.assertGreater(run_tests_sh_idx, 0)
|
||||
self.assertLess(run_tests_sh_idx, pytest_idx)
|
||||
|
||||
def test_run_tests_fail_closed_without_runner(self):
|
||||
self.assertIn("fail closed", self.content.lower())
|
||||
self.assertIn("exit 1", self.content)
|
||||
|
||||
def test_proxmox_entries_are_placeholders(self):
|
||||
self.assertIn("TODO / issue-backed", self.content)
|
||||
self.assertIn("NOT implemented", self.content)
|
||||
|
||||
def test_root_health_shows_required_fields(self):
|
||||
health_fn = self._extract_function("show_root_checkout_health")
|
||||
for snippet in (
|
||||
"status --short --branch",
|
||||
"rev-parse HEAD",
|
||||
"prgs/master",
|
||||
"WARNING: root checkout",
|
||||
):
|
||||
with self.subTest(snippet=snippet):
|
||||
self.assertIn(snippet, health_fn)
|
||||
|
||||
def test_docs_mention_mcp_menu_sh(self):
|
||||
self.assertTrue(DOCS.is_file(), "docs/mcp-menu.md must exist")
|
||||
docs_text = DOCS.read_text(encoding="utf-8")
|
||||
self.assertIn("./mcp-menu.sh", docs_text)
|
||||
self.assertIn("placeholder", docs_text.lower())
|
||||
|
||||
def _extract_function(self, name: str) -> str:
|
||||
marker = f"{name}() {{"
|
||||
start = self.content.index(marker)
|
||||
depth = 0
|
||||
for idx in range(start, len(self.content)):
|
||||
char = self.content[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return self.content[start : idx + 1]
|
||||
self.fail(f"Could not parse function {name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for MCP-native post-merge cleanup proof (#517)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from mcp_native_cleanup_proof import ( # noqa: E402
|
||||
assess_authorized_reconciler_cleanup_path,
|
||||
assess_mcp_native_cleanup_proof,
|
||||
assess_merger_cleanup_handoff_guidance,
|
||||
assess_raw_branch_delete_report,
|
||||
assess_raw_comment_delete_report,
|
||||
)
|
||||
|
||||
|
||||
def _authorized_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Merge mutations": "gitea_merge_pr on PR #100",
|
||||
"Cleanup mutations": (
|
||||
"gitea_reconcile_merged_cleanups deleted remote branch; "
|
||||
"gitea_cleanup_post_merge_moot_lease released lease"
|
||||
),
|
||||
"Reconciler capability": "prgs-reconciler / gitea.branch.delete resolved",
|
||||
"Next actor": "reconciler session complete",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestRawBranchDeleteBlocked(unittest.TestCase):
|
||||
def test_git_branch_d_blocked(self):
|
||||
report = "Cleanup mutations: git branch -d feat/issue-1-x"
|
||||
result = assess_raw_branch_delete_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("git branch -d", result["commands"][0])
|
||||
|
||||
def test_git_push_delete_in_ledger_blocked(self):
|
||||
report = "- Git ref mutations: git push origin --delete feat/x"
|
||||
result = assess_raw_branch_delete_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_narrative_git_push_delete_not_blocked(self):
|
||||
report = "\n".join([
|
||||
"## Validation",
|
||||
"Workflow §28B forbids raw git push --delete for cleanup.",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: none",
|
||||
"- Worktree mutations: none",
|
||||
])
|
||||
result = assess_raw_branch_delete_report(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_authorized_mcp_path_passes(self):
|
||||
report = _authorized_cleanup_report()
|
||||
result = assess_raw_branch_delete_report(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestRawCommentDeleteBlocked(unittest.TestCase):
|
||||
def test_curl_delete_comment_in_ledger_blocked(self):
|
||||
report = (
|
||||
"- Cleanup mutations: curl -X DELETE https://gitea.example/api/v1/repos/o/r/"
|
||||
"issues/9/comments/42"
|
||||
)
|
||||
result = assess_raw_comment_delete_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_narrative_comment_delete_not_blocked(self):
|
||||
report = "\n".join([
|
||||
"Files reviewed: post_merge_cleanup_proof.py (raw comment delete patterns)",
|
||||
"Validation note: never use delete_issue_comment for lease cleanup.",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: none",
|
||||
"- Worktree mutations: none",
|
||||
])
|
||||
result = assess_raw_comment_delete_report(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_delete_issue_comment_helper_in_ledger_blocked(self):
|
||||
report = "- Cleanup mutations: delete_issue_comment purged lease comments"
|
||||
result = assess_raw_comment_delete_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_moot_lease_append_only_passes(self):
|
||||
report = _authorized_cleanup_report()
|
||||
result = assess_raw_comment_delete_report(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestAuthorizedReconcilerPath(unittest.TestCase):
|
||||
def test_cleanup_without_mcp_tool_blocked(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: removed branch manually after merge",
|
||||
"- Reconciler capability: prgs-reconciler",
|
||||
])
|
||||
result = assess_authorized_reconciler_cleanup_path(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("authorized MCP" in r for r in result["reasons"]))
|
||||
|
||||
def test_authorized_cleanup_succeeds(self):
|
||||
result = assess_authorized_reconciler_cleanup_path(_authorized_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_git_ref_cleanup_without_mcp_tool_blocked(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: git branch -D feat/issue-517-test",
|
||||
"- Reconciler capability: prgs-reconciler",
|
||||
])
|
||||
result = assess_authorized_reconciler_cleanup_path(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("Git ref mutations cleanup must name" in r for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_worktree_cleanup_without_mcp_tool_blocked(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: none",
|
||||
"- Worktree mutations: git worktree remove branches/review-pr542",
|
||||
"- Reconciler capability: prgs-reconciler",
|
||||
])
|
||||
result = assess_authorized_reconciler_cleanup_path(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("Worktree mutations cleanup must name" in r for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_git_fetch_in_git_ref_mutations_not_cleanup_claim(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: git fetch prgs master",
|
||||
])
|
||||
result = assess_authorized_reconciler_cleanup_path(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestMergerHandoffGuidance(unittest.TestCase):
|
||||
def test_merger_cleanup_without_handoff_blocked(self):
|
||||
report = "Merger deleted remote branch and removed worktree after merge"
|
||||
result = assess_merger_cleanup_handoff_guidance(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_merger_cleanup_with_reconciler_handoff_passes(self):
|
||||
report = (
|
||||
"Merger deleted remote branch; next actor: reconciler for worktree audit"
|
||||
)
|
||||
result = assess_merger_cleanup_handoff_guidance(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestCompositeVerifier(unittest.TestCase):
|
||||
def test_full_authorized_report_passes(self):
|
||||
result = assess_mcp_native_cleanup_proof(_authorized_cleanup_report())
|
||||
self.assertFalse(result["block"], result["reasons"])
|
||||
|
||||
def test_raw_bypasses_fail_composite(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Merge mutations: gitea_merge_pr",
|
||||
"- Cleanup mutations: git branch -D feat/x; curl -X DELETE .../comments/1",
|
||||
])
|
||||
result = assess_mcp_native_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertGreaterEqual(len(result["reasons"]), 2)
|
||||
|
||||
def test_narrative_cleanup_mentions_pass_when_ledgers_none(self):
|
||||
report = "\n".join([
|
||||
"## Review summary",
|
||||
"Validated workflow §28B MCP-native cleanup guidance.",
|
||||
"Files reviewed describe raw git branch -d and delete_issue_comment blocks.",
|
||||
"",
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: none",
|
||||
"- Worktree mutations: none",
|
||||
])
|
||||
result = assess_mcp_native_cleanup_proof(report)
|
||||
self.assertFalse(result["block"], result["reasons"])
|
||||
|
||||
def test_git_ref_cleanup_bypass_blocked_in_composite(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: git branch -D feat/issue-517-test",
|
||||
"- Reconciler capability: prgs-reconciler",
|
||||
])
|
||||
result = assess_mcp_native_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("Git ref mutations cleanup must name" in r for r in result["reasons"])
|
||||
)
|
||||
|
||||
|
||||
class TestFinalReportValidatorIntegration(unittest.TestCase):
|
||||
def test_validator_blocks_raw_branch_delete_in_review_report(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: git branch -d feat/issue-517-test",
|
||||
])
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
rules = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("shared.mcp_native_cleanup_proof", rules)
|
||||
|
||||
def test_validator_passes_authorized_cleanup_report(self):
|
||||
result = assess_final_report_validator(_authorized_cleanup_report(), "review_pr")
|
||||
blocked = [f for f in result["findings"] if f.get("severity") == "block"]
|
||||
mcp_blocks = [
|
||||
f for f in blocked if f.get("rule_id") == "shared.mcp_native_cleanup_proof"
|
||||
]
|
||||
self.assertEqual(mcp_blocks, [])
|
||||
|
||||
def test_validator_passes_narrative_cleanup_mentions_with_none_ledgers(self):
|
||||
report = "\n".join([
|
||||
"## Review summary",
|
||||
"Workflow §28B documents raw git push --delete and delete_issue_comment blocks.",
|
||||
"",
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: none",
|
||||
"- Worktree mutations: none",
|
||||
])
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
mcp_blocks = [
|
||||
f for f in result["findings"]
|
||||
if f.get("rule_id") == "shared.mcp_native_cleanup_proof"
|
||||
and f.get("severity") == "block"
|
||||
]
|
||||
self.assertEqual(mcp_blocks, [])
|
||||
|
||||
def test_validator_blocks_git_ref_cleanup_bypass(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup mutations: none",
|
||||
"- Git ref mutations: git branch -D feat/issue-517-test",
|
||||
"- Reconciler capability: prgs-reconciler",
|
||||
])
|
||||
result = assess_final_report_validator(report, "reconcile_already_landed")
|
||||
rules = {f["rule_id"] for f in result["findings"] if f.get("severity") == "block"}
|
||||
self.assertIn("shared.mcp_native_cleanup_proof", rules)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+172
-2
@@ -127,6 +127,7 @@ def _install_owned_reviewer_lease(
|
||||
session_id=_DEFAULT_LEASE_SESSION,
|
||||
head_sha="abc123",
|
||||
):
|
||||
import merger_lease_adoption as mla
|
||||
import reviewer_pr_lease
|
||||
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
@@ -135,7 +136,11 @@ def _install_owned_reviewer_lease(
|
||||
"session_id": session_id,
|
||||
"candidate_head": head_sha,
|
||||
"target_branch": "master",
|
||||
})
|
||||
"comment_id": 9001,
|
||||
}, lease_provenance=mla.build_lease_provenance(
|
||||
source=mla.SOURCE_ACQUIRE,
|
||||
comment_id=9001,
|
||||
))
|
||||
return patch(
|
||||
"mcp_server._fetch_pr_comments",
|
||||
return_value=[
|
||||
@@ -1057,7 +1062,8 @@ class TestMergePR(unittest.TestCase):
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||
pr_number=8, confirmation=self._confirm(8), remote="prgs",
|
||||
expected_head_sha=new_sha)
|
||||
self.assertFalse(r["performed"])
|
||||
self.assertTrue(r.get("approval_visible"))
|
||||
self.assertFalse(r.get("approval_at_current_head"))
|
||||
@@ -3959,3 +3965,167 @@ class TestPreflightVerification(unittest.TestCase):
|
||||
self.assertIn("worktree_path argument", msg)
|
||||
self.assertIn("task_file.py", msg)
|
||||
self.assertIn("author namespace", msg)
|
||||
|
||||
|
||||
class TestIssue546Deadlock(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import reviewer_pr_lease
|
||||
import review_workflow_load
|
||||
import mcp_server
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server._preflight_capability_called = False
|
||||
mcp_server._preflight_resolved_role = None
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
|
||||
self.auth_user_patch = patch("mcp_server._authenticated_username", return_value="reviewer-bot")
|
||||
self.auth_user_patch.start()
|
||||
self.addCleanup(self.auth_user_patch.stop)
|
||||
|
||||
self.verify_purity_patch = patch("mcp_server.verify_preflight_purity", return_value=None)
|
||||
self.verify_purity_patch.start()
|
||||
self.addCleanup(self.verify_purity_patch.stop)
|
||||
|
||||
self.verify_workspace_patch = patch("mcp_server._verify_role_mutation_workspace", return_value="/workspace")
|
||||
self.verify_workspace_patch.start()
|
||||
self.addCleanup(self.verify_workspace_patch.stop)
|
||||
|
||||
self.get_profile_patch = patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.review", "gitea.pr.approve"],
|
||||
"forbidden_operations": [],
|
||||
})
|
||||
self.get_profile_patch.start()
|
||||
self.addCleanup(self.get_profile_patch.stop)
|
||||
|
||||
self.pr_work_lease_patch = patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block",
|
||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
||||
)
|
||||
self.pr_work_lease_patch.start()
|
||||
self.addCleanup(self.pr_work_lease_patch.stop)
|
||||
|
||||
self.auth_header_patch = patch("mcp_server.get_auth_header", return_value="token test")
|
||||
self.auth_header_patch.start()
|
||||
self.addCleanup(self.auth_header_patch.stop)
|
||||
|
||||
def test_workflow_no_deadlock(self):
|
||||
import mcp_server
|
||||
import reviewer_pr_lease
|
||||
|
||||
# 1. Resolve capability
|
||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
|
||||
# 2. Load review workflow
|
||||
res = mcp_server.gitea_load_review_workflow()
|
||||
self.assertTrue(res["success"])
|
||||
|
||||
# Mock API requests for lease comments and PR retrieval
|
||||
comments = []
|
||||
def mock_api_side(method, url, auth, data=None):
|
||||
if "/api/v1/user" in url:
|
||||
return {"login": "reviewer-bot"}
|
||||
if "/pulls/" in url:
|
||||
if "/reviews" in url:
|
||||
if method == "POST":
|
||||
return {"id": 2001, "state": "APPROVED"}
|
||||
return [{"id": 2001, "user": {"login": "reviewer-bot"}, "state": "APPROVED", "commit_id": "abc123"}]
|
||||
return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True}
|
||||
if "/comments" in url:
|
||||
if method == "POST":
|
||||
comments.append({"id": 1001, "body": data["body"], "user": {"login": "reviewer-bot"}})
|
||||
return {"id": 1001}
|
||||
return comments
|
||||
return {}
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=mock_api_side):
|
||||
# 3. Acquire reviewer lease
|
||||
res = mcp_server.gitea_acquire_reviewer_pr_lease(
|
||||
pr_number=550,
|
||||
worktree="/workspace",
|
||||
candidate_head="abc123",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["comment_id"], 1001)
|
||||
|
||||
# verify lease is recorded in session
|
||||
session_lease = reviewer_pr_lease.get_session_lease()
|
||||
self.assertIsNotNone(session_lease)
|
||||
self.assertEqual(session_lease["pr_number"], 550)
|
||||
|
||||
# 4. Resolve capability again (simulating subsequent tool call preflight check/token refresh)
|
||||
# This should NOT clear the session lease or decision lock!
|
||||
mcp_server._preflight_capability_called = False # simulate clearance from prior mutation
|
||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
self.assertTrue(res["allowed_in_current_session"])
|
||||
self.assertIsNotNone(reviewer_pr_lease.get_session_lease()) # Preserved!
|
||||
|
||||
# 5. Mark final review decision APPROVED
|
||||
res = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=550,
|
||||
action="approve",
|
||||
expected_head_sha="abc123",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res["marked_ready"])
|
||||
|
||||
# 6. Submit review (should succeed without deadlock)
|
||||
res = mcp_server.gitea_submit_pr_review(
|
||||
pr_number=550,
|
||||
action="approve",
|
||||
body="APPROVED",
|
||||
expected_head_sha="abc123",
|
||||
final_review_decision_ready=True,
|
||||
remote="prgs",
|
||||
worktree_path="/workspace",
|
||||
)
|
||||
self.assertTrue(res["performed"])
|
||||
|
||||
def test_release_reviewer_pr_lease(self):
|
||||
import mcp_server
|
||||
import reviewer_pr_lease
|
||||
|
||||
# Resolve capability and load workflow
|
||||
mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
comments = [_reviewer_lease_comment(550, session_id=_DEFAULT_LEASE_SESSION, head_sha="abc123")]
|
||||
posted_comments = []
|
||||
|
||||
def mock_api_side(method, url, auth, data=None):
|
||||
if "/api/v1/user" in url:
|
||||
return {"login": "reviewer-bot"}
|
||||
if "/pulls/" in url:
|
||||
return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True}
|
||||
if "/comments" in url:
|
||||
if method == "POST":
|
||||
new_c = {"id": 1002, "body": data["body"], "user": {"login": "reviewer-bot"}}
|
||||
comments.append(new_c)
|
||||
posted_comments.append(new_c)
|
||||
return {"id": 1002}
|
||||
return comments
|
||||
return {}
|
||||
|
||||
# Set session lease in memory
|
||||
import merger_lease_adoption as mla
|
||||
reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": 550,
|
||||
"session_id": _DEFAULT_LEASE_SESSION,
|
||||
"candidate_head": "abc123",
|
||||
"target_branch": "master",
|
||||
"comment_id": 9001,
|
||||
}, lease_provenance=mla.build_lease_provenance(source=mla.SOURCE_ACQUIRE, comment_id=9001))
|
||||
|
||||
with patch("mcp_server.api_request", side_effect=mock_api_side):
|
||||
# Release lease
|
||||
res = mcp_server.gitea_release_reviewer_pr_lease(pr_number=550, remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertTrue(res["released"])
|
||||
self.assertEqual(res["comment_id"], 1002)
|
||||
|
||||
# verify lease is cleared in session
|
||||
self.assertIsNone(reviewer_pr_lease.get_session_lease())
|
||||
# verify comment phase is released
|
||||
self.assertIn("phase: released", posted_comments[0]["body"])
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Merger lease adoption / recovery (#536)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import merger_lease_adoption as mla
|
||||
import reviewer_pr_lease as leases
|
||||
|
||||
|
||||
def _lease_comment(
|
||||
pr_number: int,
|
||||
session_id: str,
|
||||
*,
|
||||
phase: str = "claimed",
|
||||
profile: str = "prgs-reviewer",
|
||||
identity: str = "sysadmin",
|
||||
candidate_head: str = "a" * 40,
|
||||
comment_id: int = 100,
|
||||
) -> dict:
|
||||
body = leases.format_lease_body(
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
pr_number=pr_number,
|
||||
issue_number=536,
|
||||
reviewer_identity=identity,
|
||||
profile=profile,
|
||||
session_id=session_id,
|
||||
worktree="branches/review-pr536",
|
||||
phase=phase,
|
||||
candidate_head=candidate_head,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
last_activity=datetime.now(timezone.utc),
|
||||
)
|
||||
return {"id": comment_id, "body": body, "user": {"login": identity}}
|
||||
|
||||
|
||||
HEAD = "f" * 40
|
||||
|
||||
|
||||
class TestSanctionedProvenance(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
|
||||
def test_manual_session_seed_rejected_by_mutation_gate(self):
|
||||
comments = [_lease_comment(536, "reviewer-session", candidate_head=HEAD)]
|
||||
leases.record_session_lease({
|
||||
"pr_number": 536,
|
||||
"session_id": "merger-session",
|
||||
"candidate_head": HEAD,
|
||||
"comment_id": 999,
|
||||
})
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=536,
|
||||
comments=comments,
|
||||
reviewer_identity="sysadmin",
|
||||
session_id="merger-session",
|
||||
mutation="merge",
|
||||
live_head_sha=HEAD,
|
||||
pinned_head_sha=HEAD,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("sanctioned provenance" in r for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_sanctioned_acquire_provenance_allows_mutation(self):
|
||||
comments = [_lease_comment(536, "my-session", candidate_head=HEAD)]
|
||||
leases.record_session_lease(
|
||||
{
|
||||
"pr_number": 536,
|
||||
"session_id": "my-session",
|
||||
"candidate_head": HEAD,
|
||||
"comment_id": 101,
|
||||
},
|
||||
lease_provenance=mla.build_lease_provenance(
|
||||
source=mla.SOURCE_ACQUIRE,
|
||||
comment_id=101,
|
||||
),
|
||||
)
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=536,
|
||||
comments=comments,
|
||||
reviewer_identity="sysadmin",
|
||||
session_id="my-session",
|
||||
mutation="merge",
|
||||
live_head_sha=HEAD,
|
||||
pinned_head_sha=HEAD,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestAdoptAssessment(unittest.TestCase):
|
||||
def test_adopt_allowed_for_merger_with_reviewer_lease_and_approval(self):
|
||||
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
|
||||
result = mla.assess_adopt_merger_lease(
|
||||
comments,
|
||||
pr_number=536,
|
||||
adopter_identity="sysadmin",
|
||||
adopter_profile="prgs-merger",
|
||||
adopter_session_id="merger-sess",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=536,
|
||||
worktree="branches/merge-pr536",
|
||||
expected_head_sha=HEAD,
|
||||
live_head_sha=HEAD,
|
||||
approval_at_head=True,
|
||||
pr_open=True,
|
||||
)
|
||||
self.assertTrue(result["adopt_allowed"])
|
||||
self.assertIn(mla.ADOPTION_MARKER, result["adoption_body"])
|
||||
self.assertIn("adopted_from_session_id: reviewer-sess", result["adoption_body"])
|
||||
|
||||
def test_adopt_blocked_without_approval(self):
|
||||
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
|
||||
result = mla.assess_adopt_merger_lease(
|
||||
comments,
|
||||
pr_number=536,
|
||||
adopter_identity="sysadmin",
|
||||
adopter_profile="prgs-merger",
|
||||
adopter_session_id="merger-sess",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=536,
|
||||
worktree="branches/merge-pr536",
|
||||
expected_head_sha=HEAD,
|
||||
live_head_sha=HEAD,
|
||||
approval_at_head=False,
|
||||
pr_open=True,
|
||||
)
|
||||
self.assertFalse(result["adopt_allowed"])
|
||||
self.assertTrue(any("APPROVED" in r for r in result["reasons"]))
|
||||
|
||||
def test_adopt_blocked_for_non_merger_profile(self):
|
||||
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
|
||||
result = mla.assess_adopt_merger_lease(
|
||||
comments,
|
||||
pr_number=536,
|
||||
adopter_identity="sysadmin",
|
||||
adopter_profile="prgs-reviewer",
|
||||
adopter_session_id="merger-sess",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=536,
|
||||
worktree="branches/review-pr536",
|
||||
expected_head_sha=HEAD,
|
||||
live_head_sha=HEAD,
|
||||
approval_at_head=True,
|
||||
pr_open=True,
|
||||
)
|
||||
self.assertFalse(result["adopt_allowed"])
|
||||
self.assertTrue(any("merger profile" in r for r in result["reasons"]))
|
||||
|
||||
def test_adopt_blocked_when_pr_not_open(self):
|
||||
comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)]
|
||||
result = mla.assess_adopt_merger_lease(
|
||||
comments,
|
||||
pr_number=536,
|
||||
adopter_identity="sysadmin",
|
||||
adopter_profile="prgs-merger",
|
||||
adopter_session_id="merger-sess",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=536,
|
||||
worktree="branches/merge-pr536",
|
||||
expected_head_sha=HEAD,
|
||||
live_head_sha=HEAD,
|
||||
approval_at_head=True,
|
||||
pr_open=False,
|
||||
)
|
||||
self.assertFalse(result["adopt_allowed"])
|
||||
|
||||
|
||||
class TestMergerHandoffMutationGate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
|
||||
def test_cross_session_merge_blocked_without_adoption_comment(self):
|
||||
reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD)
|
||||
provenance = mla.build_lease_provenance(
|
||||
source=mla.SOURCE_ADOPT,
|
||||
comment_id=7001,
|
||||
adopted_from_session_id="reviewer-sess",
|
||||
)
|
||||
leases.record_session_lease(
|
||||
{
|
||||
"pr_number": 536,
|
||||
"session_id": "merger-sess",
|
||||
"candidate_head": HEAD,
|
||||
"comment_id": 7001,
|
||||
"phase": "adopted",
|
||||
},
|
||||
lease_provenance=provenance,
|
||||
)
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=536,
|
||||
comments=[reviewer_comment],
|
||||
reviewer_identity="sysadmin",
|
||||
session_id="merger-sess",
|
||||
mutation="merge",
|
||||
live_head_sha=HEAD,
|
||||
pinned_head_sha=HEAD,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("owned by session_id=reviewer-sess" in r for r in result["reasons"]))
|
||||
|
||||
def test_cross_session_merge_allowed_after_adoption_comment(self):
|
||||
reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD)
|
||||
adoption_body = mla.format_adoption_body(
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
pr_number=536,
|
||||
issue_number=536,
|
||||
adopter_identity="sysadmin",
|
||||
adopter_profile="prgs-merger",
|
||||
adopter_session_id="merger-sess",
|
||||
worktree="branches/merge-pr536",
|
||||
candidate_head=HEAD,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
adopted_from_session_id="reviewer-sess",
|
||||
adopted_from_profile="prgs-reviewer",
|
||||
adopted_from_reviewer_identity="sysadmin",
|
||||
adopted_from_comment_id=100,
|
||||
)
|
||||
adoption_comment = {
|
||||
"id": 7001,
|
||||
"body": adoption_body,
|
||||
"user": {"login": "sysadmin"},
|
||||
}
|
||||
provenance = mla.build_lease_provenance(
|
||||
source=mla.SOURCE_ADOPT,
|
||||
comment_id=7001,
|
||||
adopted_from_session_id="reviewer-sess",
|
||||
)
|
||||
leases.record_session_lease(
|
||||
{
|
||||
"pr_number": 536,
|
||||
"session_id": "merger-sess",
|
||||
"candidate_head": HEAD,
|
||||
"comment_id": 7001,
|
||||
"phase": "adopted",
|
||||
},
|
||||
lease_provenance=provenance,
|
||||
)
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=536,
|
||||
comments=[reviewer_comment, adoption_comment],
|
||||
reviewer_identity="sysadmin",
|
||||
session_id="merger-sess",
|
||||
mutation="merge",
|
||||
live_head_sha=HEAD,
|
||||
pinned_head_sha=HEAD,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestAdoptTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
# Patch only the role workspace verifier (which internally does the single
|
||||
# verify_preflight_purity); the direct duplicate call was removed to prevent
|
||||
# capability state clearing. See fix for #548.
|
||||
patch("mcp_server._verify_role_mutation_workspace", return_value=None).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
mcp_server = __import__("mcp_server")
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server.init_review_decision_lock("prgs", "adopt_merger_pr_lease")
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", "reviewer", resolved_task="adopt_merger_pr_lease"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
leases.clear_session_lease()
|
||||
|
||||
@patch("mcp_server._authenticated_username", return_value="sysadmin")
|
||||
@patch("mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0")
|
||||
@patch("mcp_server.get_profile")
|
||||
@patch("mcp_server.api_request")
|
||||
def test_adopt_tool_posts_proof_and_records_sanctioned_session(
|
||||
self, mock_api, mock_profile, _mock_auth, _mock_user
|
||||
):
|
||||
mock_profile.return_value = {
|
||||
"profile_name": "prgs-merger",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD)
|
||||
posted_bodies: list[str] = []
|
||||
|
||||
def _side(method, url, auth=None, payload=None, *a, **k):
|
||||
m = (method or "").upper()
|
||||
if m == "POST":
|
||||
if payload and payload.get("body"):
|
||||
posted_bodies.append(payload["body"])
|
||||
return {"id": 7001}
|
||||
if "/pulls/" in url and "/files" not in url:
|
||||
return {
|
||||
"state": "open",
|
||||
"number": 536,
|
||||
"head": {"sha": HEAD},
|
||||
"merged": False,
|
||||
}
|
||||
if "/reviews" in url:
|
||||
return []
|
||||
if "/comments" in url:
|
||||
return [reviewer_comment]
|
||||
return {}
|
||||
|
||||
mock_api.side_effect = _side
|
||||
from mcp_server import gitea_adopt_merger_pr_lease
|
||||
|
||||
with patch.object(
|
||||
__import__("mcp_server"),
|
||||
"gitea_get_pr_review_feedback",
|
||||
return_value={
|
||||
"approval_at_current_head": True,
|
||||
"latest_approved_head_sha": HEAD,
|
||||
},
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_PROFILE_NAME": "prgs-merger",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment,gitea.pr.merge",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
result = gitea_adopt_merger_pr_lease(
|
||||
pr_number=536,
|
||||
worktree="branches/merge-pr536",
|
||||
expected_head_sha=HEAD,
|
||||
issue_number=536,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["adopted"])
|
||||
self.assertEqual(result["adopted_from_session_id"], "reviewer-sess")
|
||||
self.assertEqual(result["adoption_comment_id"], 7001)
|
||||
self.assertEqual(len(posted_bodies), 1)
|
||||
self.assertIn(mla.ADOPTION_MARKER, posted_bodies[0])
|
||||
|
||||
session = leases.get_session_lease()
|
||||
self.assertTrue(mla.is_sanctioned_session_lease(session))
|
||||
|
||||
adoption_comment = {
|
||||
"id": 7001,
|
||||
"body": posted_bodies[0],
|
||||
"user": {"login": "sysadmin"},
|
||||
}
|
||||
gate = leases.assess_mutation_lease_gate(
|
||||
pr_number=536,
|
||||
comments=[reviewer_comment, adoption_comment],
|
||||
reviewer_identity="sysadmin",
|
||||
session_id=result["session_id"],
|
||||
mutation="merge",
|
||||
live_head_sha=HEAD,
|
||||
pinned_head_sha=HEAD,
|
||||
)
|
||||
self.assertFalse(gate["block"])
|
||||
|
||||
def test_adopt_preserves_preflight_capability_state_no_duplicate_clear(self):
|
||||
"""Prove fix for #548: adopt calls _verify (single purity) and does not
|
||||
trigger duplicate verify that would clear capability state, avoiding
|
||||
need for temp local patches or raw fallbacks.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import mcp_server as mcp_server_mod
|
||||
|
||||
# fresh preflight state (as controller/reconciler would have from resolve)
|
||||
mcp_server_mod.record_preflight_check("whoami")
|
||||
mcp_server_mod.record_preflight_check(
|
||||
"capability", "merger", resolved_task="adopt_merger_pr_lease"
|
||||
)
|
||||
initial_cap_called = mcp_server_mod._preflight_capability_called
|
||||
|
||||
verify_patch = patch("mcp_server.verify_preflight_purity", wraps=mcp_server_mod.verify_preflight_purity)
|
||||
def _role_side(*a, **k):
|
||||
# simulate the real _verify which calls verify once with path
|
||||
mcp_server_mod.verify_preflight_purity(k.get("remote") or a[0] if a else None, worktree_path=k.get("worktree") or "branches/work", task=k.get("task"))
|
||||
return "branches/work"
|
||||
role_patch = patch("mcp_server._verify_role_mutation_workspace", side_effect=_role_side)
|
||||
with verify_patch as vmock, role_patch, \
|
||||
patch("mcp_server.get_auth_header", return_value="token test"), \
|
||||
patch("mcp_server._authenticated_username", return_value="sysadmin"):
|
||||
# minimal mocks to let adopt reach end without full api
|
||||
with patch("mcp_server.get_profile") as gp:
|
||||
gp.return_value = {
|
||||
"profile_name": "prgs-merger",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
with patch("mcp_server.api_request") as api:
|
||||
def _side(url, *a, **k):
|
||||
if "/pulls/" in str(url) and "/comments" not in str(url):
|
||||
return {"state": "open", "head": {"sha": "deadbeef"*5}, "merged": False, "number": 999}
|
||||
if "/comments" in str(url):
|
||||
return [] # list of comments for fetch
|
||||
return {"id": 42}
|
||||
api.side_effect = _side
|
||||
with patch("mcp_server.gitea_get_pr_review_feedback", return_value={"approval_at_current_head": True}):
|
||||
with patch("merger_lease_adoption.assess_adopt_merger_lease", return_value={
|
||||
"adopt_allowed": True,
|
||||
"active_lease": {"session_id": "rev", "profile": "prgs-reviewer", "reviewer_identity": "sysadmin", "issue_number": 548, "target_branch": "master"},
|
||||
"adoption_body": "<!-- mcp-review-lease-adoption:v1 --> adopted",
|
||||
}):
|
||||
res = mcp_server_mod.gitea_adopt_merger_pr_lease(
|
||||
pr_number=999,
|
||||
worktree="branches/merge-test",
|
||||
expected_head_sha="deadbeef"*5,
|
||||
issue_number=548,
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertTrue(res.get("success"))
|
||||
# verify_preflight_purity should have been invoked exactly once (via the _verify_role path)
|
||||
# not twice (old dup would have cleared state mid-way, requiring temp patches)
|
||||
self.assertEqual(vmock.call_count, 1)
|
||||
# capability state management: the single verify consumes (clears) as designed;
|
||||
# prior state was valid, no unexpected clear between "checks" or hidden reset
|
||||
# (the removed dup line was the source of the #548 clearing bug)
|
||||
self.assertTrue(initial_cap_called)
|
||||
# final may be set by other records in flow, but key is single invocation and success without bypasses
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -95,8 +95,12 @@ class PermissionReportBase(unittest.TestCase):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
self._write_config(CONFIG)
|
||||
import review_workflow_load
|
||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||
|
||||
def tearDown(self):
|
||||
import review_workflow_load
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
@@ -26,6 +26,11 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
|
||||
result = _list_pr_lease_comments(
|
||||
12, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertEqual(result, [])
|
||||
# Single comments GET only — no pre-flight PR lookup (#519 isolation).
|
||||
mock_api.assert_called_once()
|
||||
_method, url, _auth_arg = mock_api.call_args[0][:3]
|
||||
self.assertIn("/issues/12/comments", url)
|
||||
self.assertNotIn("/pulls/", url)
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@@ -73,4 +78,4 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Regression coverage for the remote/repo mismatch guard (#530).
|
||||
|
||||
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
|
||||
(the hardcoded ``REMOTES`` default) instead of ``Scaled-Tech-Consulting/Gitea-Tools``,
|
||||
which is what the local git remote actually points at. That silent mismatch caused
|
||||
false 404s and risked mutating the wrong repository. The guard fails closed when the
|
||||
MCP-resolved org/repo disagrees with the local git remote URL and the caller did not
|
||||
pass explicit ``org``/``repo``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import remote_repo_guard
|
||||
|
||||
|
||||
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
|
||||
|
||||
class TestAssessRemoteRepoMatch(unittest.TestCase):
|
||||
def test_explicit_org_and_repo_skips_guard(self):
|
||||
# Caller supplied both org and repo explicitly: never block, even on mismatch.
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org="Scaled-Tech-Consulting",
|
||||
resolved_repo="Timesheet",
|
||||
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||
org_explicit=True,
|
||||
repo_explicit=True,
|
||||
)
|
||||
self.assertTrue(assessment["proven"])
|
||||
self.assertFalse(assessment["block"])
|
||||
self.assertEqual(assessment["reasons"], [])
|
||||
|
||||
def test_missing_local_remote_url_skips_guard(self):
|
||||
# Best-effort lookup: no local remote URL (non-checkout usage) => do not block.
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org="Scaled-Tech-Consulting",
|
||||
resolved_repo="Timesheet",
|
||||
local_remote_url=None,
|
||||
org_explicit=False,
|
||||
repo_explicit=False,
|
||||
)
|
||||
self.assertTrue(assessment["proven"])
|
||||
self.assertFalse(assessment["block"])
|
||||
|
||||
def test_matching_repo_is_proven(self):
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org="Scaled-Tech-Consulting",
|
||||
resolved_repo="Gitea-Tools",
|
||||
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||
org_explicit=False,
|
||||
repo_explicit=False,
|
||||
)
|
||||
self.assertTrue(assessment["proven"])
|
||||
self.assertFalse(assessment["block"])
|
||||
|
||||
def test_regression_prgs_default_timesheet_vs_local_gitea_tools(self):
|
||||
# The exact #530 scenario: default repo Timesheet, local remote Gitea-Tools.
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org="Scaled-Tech-Consulting",
|
||||
resolved_repo="Timesheet",
|
||||
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||
org_explicit=False,
|
||||
repo_explicit=False,
|
||||
)
|
||||
self.assertFalse(assessment["proven"])
|
||||
self.assertTrue(assessment["block"])
|
||||
self.assertTrue(assessment["reasons"])
|
||||
self.assertEqual(assessment["resolved_repo"], "Timesheet")
|
||||
self.assertEqual(assessment["resolved_org"], "Scaled-Tech-Consulting")
|
||||
|
||||
def test_only_org_explicit_still_checks_repo(self):
|
||||
# Repo left as default => still guarded even if org was explicit.
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org="Scaled-Tech-Consulting",
|
||||
resolved_repo="Timesheet",
|
||||
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||
org_explicit=True,
|
||||
repo_explicit=False,
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
|
||||
def test_case_insensitive_match(self):
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org="scaled-tech-consulting",
|
||||
resolved_repo="gitea-tools",
|
||||
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||
org_explicit=False,
|
||||
repo_explicit=False,
|
||||
)
|
||||
self.assertTrue(assessment["proven"])
|
||||
|
||||
def test_format_error_mentions_resolved_and_local(self):
|
||||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote="prgs",
|
||||
resolved_org="Scaled-Tech-Consulting",
|
||||
resolved_repo="Timesheet",
|
||||
local_remote_url=LOCAL_GITEA_TOOLS_URL,
|
||||
org_explicit=False,
|
||||
repo_explicit=False,
|
||||
)
|
||||
message = remote_repo_guard.format_remote_repo_guard_error(assessment)
|
||||
self.assertIn("Timesheet", message)
|
||||
self.assertIn("Gitea-Tools", message)
|
||||
self.assertIn("org=", message)
|
||||
self.assertIn("repo=", message)
|
||||
|
||||
|
||||
class TestResolveServerWiring(unittest.TestCase):
|
||||
"""The guard is wired into gitea_mcp_server._resolve, so every read/lookup/
|
||||
mutation tool that resolves a target fails closed on a repo mismatch.
|
||||
|
||||
Under pytest the guard is bypassed unless GITEA_FORCE_REMOTE_REPO_CHECK is set,
|
||||
so these tests set the force flag and patch the local remote lookup + REMOTES.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
import gitea_mcp_server
|
||||
|
||||
self.server = gitea_mcp_server
|
||||
|
||||
force = mock.patch.dict(
|
||||
os.environ, {"GITEA_FORCE_REMOTE_REPO_CHECK": "1"}, clear=False
|
||||
)
|
||||
force.start()
|
||||
self.addCleanup(force.stop)
|
||||
|
||||
url_patch = mock.patch.object(
|
||||
gitea_mcp_server,
|
||||
"_local_git_remote_url",
|
||||
return_value=LOCAL_GITEA_TOOLS_URL,
|
||||
)
|
||||
url_patch.start()
|
||||
self.addCleanup(url_patch.stop)
|
||||
|
||||
def _set_prgs_default_repo(self, repo):
|
||||
original = dict(self.server.REMOTES["prgs"])
|
||||
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
|
||||
self.server.REMOTES["prgs"] = {**original, "repo": repo}
|
||||
|
||||
def test_resolve_blocks_on_default_repo_mismatch(self):
|
||||
self._set_prgs_default_repo("Timesheet")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
self.server._resolve("prgs", None, None, None)
|
||||
self.assertIn("Timesheet", str(ctx.exception))
|
||||
self.assertIn("Gitea-Tools", str(ctx.exception))
|
||||
|
||||
def test_resolve_allows_explicit_org_and_repo(self):
|
||||
self._set_prgs_default_repo("Timesheet")
|
||||
# Explicit org/repo is authoritative even if it does not match local remote.
|
||||
host, org, repo = self.server._resolve(
|
||||
"prgs", None, "Scaled-Tech-Consulting", "Timesheet"
|
||||
)
|
||||
self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Timesheet"))
|
||||
|
||||
def test_resolve_allows_matching_default(self):
|
||||
self._set_prgs_default_repo("Gitea-Tools")
|
||||
host, org, repo = self.server._resolve("prgs", None, None, None)
|
||||
self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||
|
||||
def test_lookup_tool_cannot_silently_query_different_repo(self):
|
||||
# gitea_view_issue resolves the target via _resolve first, so a bare
|
||||
# remote=prgs pointing at the wrong default repo fails closed before any
|
||||
# API call is made.
|
||||
self._set_prgs_default_repo("Timesheet")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.server.gitea_view_issue(issue_number=1, remote="prgs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for workflow-load session boundary tracking (#403)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import final_report_validator
|
||||
import review_workflow_boundary
|
||||
import review_workflow_load
|
||||
import mcp_server
|
||||
|
||||
|
||||
class TestPreReviewClassification(unittest.TestCase):
|
||||
def setUp(self):
|
||||
review_workflow_boundary.clear_pre_review_commands()
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_inventory_command_allowed(self):
|
||||
result = review_workflow_boundary.classify_pre_review_command(
|
||||
"gitea_list_prs remote=prgs",
|
||||
cwd="/tmp",
|
||||
project_root="/repo/Gitea-Tools",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["classification"],
|
||||
review_workflow_boundary.CLASSIFICATION_READ_ONLY_INVENTORY,
|
||||
)
|
||||
|
||||
def test_main_checkout_pytest_is_boundary_violation(self):
|
||||
root = "/repo/Gitea-Tools"
|
||||
result = review_workflow_boundary.classify_pre_review_command(
|
||||
"python -m pytest tests/",
|
||||
cwd=root,
|
||||
project_root=root,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["classification"],
|
||||
review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
)
|
||||
|
||||
def test_profiles_json_inspection_is_boundary_violation(self):
|
||||
result = review_workflow_boundary.classify_pre_review_command(
|
||||
"cat profiles.json",
|
||||
cwd="/repo/Gitea-Tools",
|
||||
project_root="/repo/Gitea-Tools",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["classification"],
|
||||
review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION,
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowLoadBoundaryGate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
review_workflow_boundary.clear_pre_review_commands()
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", "reviewer")
|
||||
|
||||
def _root(self) -> str:
|
||||
return str(__import__("pathlib").Path(__file__).resolve().parent.parent)
|
||||
|
||||
def test_boundary_violation_blocks_mutation_after_load(self):
|
||||
root = self._root()
|
||||
review_workflow_boundary.record_pre_review_command(
|
||||
"python -m pytest tests/",
|
||||
cwd=root,
|
||||
project_root=root,
|
||||
)
|
||||
res = mcp_server.gitea_load_review_workflow()
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["boundary_status"], "violation")
|
||||
blocked = mcp_server.gitea_mark_final_review_decision(
|
||||
42, "approve", remote="prgs")
|
||||
self.assertFalse(blocked["marked_ready"])
|
||||
joined = " ".join(blocked["reasons"]).lower()
|
||||
self.assertTrue(
|
||||
"validation" in joined or "boundary" in joined or "workflow" in joined
|
||||
)
|
||||
|
||||
def test_clean_inventory_then_load_passes(self):
|
||||
root = self._root()
|
||||
review_workflow_boundary.record_pre_review_command(
|
||||
"gitea_list_prs remote=prgs",
|
||||
cwd=root,
|
||||
project_root=root,
|
||||
)
|
||||
res = mcp_server.gitea_load_review_workflow()
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["boundary_status"], "clean")
|
||||
blockers = review_workflow_load.review_workflow_load_blockers(root)
|
||||
self.assertEqual(blockers, [])
|
||||
|
||||
def test_file_view_narrative_fails_validator_without_helper(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: review-merge-pr\n"
|
||||
"- I read the canonical workflow review-merge-pr.md before review.\n"
|
||||
)
|
||||
findings = final_report_validator.assess_final_report_validator(
|
||||
report,
|
||||
task_kind="review_pr",
|
||||
)
|
||||
self.assertTrue(any(
|
||||
f["rule_id"] == "reviewer.workflow_load_boundary"
|
||||
for f in findings.get("findings") or []
|
||||
))
|
||||
|
||||
def test_helper_result_passes_validator(self):
|
||||
root = self._root()
|
||||
review_workflow_load.record_review_workflow_load(root)
|
||||
helper = review_workflow_boundary.workflow_load_helper_result(
|
||||
review_workflow_load._REVIEW_WORKFLOW_LOAD,
|
||||
root,
|
||||
)
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: review-merge-pr\n"
|
||||
f"- Workflow-load helper result: workflow_hash: {helper['workflow_hash']}; "
|
||||
f"boundary_status: {helper['boundary_status']}\n"
|
||||
)
|
||||
findings = final_report_validator.assess_final_report_validator(
|
||||
report,
|
||||
task_kind="review_pr",
|
||||
)
|
||||
boundary_findings = [
|
||||
f for f in (findings.get("findings") or [])
|
||||
if f.get("rule_id") == "reviewer.workflow_load_boundary"
|
||||
]
|
||||
self.assertEqual(boundary_findings, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for exact per-mutation capability proof in reviewer reports (#405)."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from reviewer_mutation_capability_proof import assess_mutation_capability_proof
|
||||
from review_proofs import assess_mutation_capability_proof as proofs_assess
|
||||
from final_report_validator import assess_final_report_validator
|
||||
|
||||
|
||||
REVIEW_ONLY = """
|
||||
Review decision: approved
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | resolved before review
|
||||
"""
|
||||
|
||||
MERGE_PROVEN = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | resolved before merge
|
||||
"""
|
||||
|
||||
MERGE_AND_DELETE_PROVEN = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Remote branch deleted: feat/issue-x
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
|
||||
- gitea_delete_branch | delete_branch (gitea.branch.delete) | deleted | before delete
|
||||
"""
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
def test_review_only_with_capability_passes(self):
|
||||
r = assess_mutation_capability_proof(REVIEW_ONLY)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_merge_with_exact_capability_passes(self):
|
||||
r = assess_mutation_capability_proof(MERGE_PROVEN)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_merge_and_delete_fully_proven_passes(self):
|
||||
r = assess_mutation_capability_proof(MERGE_AND_DELETE_PROVEN)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_review_pr_does_not_authorize_merge(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("merge" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_merge_pr_does_not_authorize_branch_deletion(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Remote branch deleted: feat/issue-x
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("delet" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_delete_skipped_when_capability_missing_passes(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Branch deletion: skipped — delete_branch capability not available
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
def test_missing_table_when_merging_blocks(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
merge_pr gitea.pr.merge resolved
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("table" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_post_hoc_proof_blocks(self):
|
||||
report = """
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | capability resolved after merge
|
||||
"""
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
self.assertTrue(any("after" in x.lower() for x in r["reasons"]), r["reasons"])
|
||||
|
||||
def test_review_without_capability_blocks(self):
|
||||
report = "Review decision: approved\nreview submitted\n"
|
||||
r = assess_mutation_capability_proof(report)
|
||||
self.assertFalse(r["proven"])
|
||||
|
||||
def test_no_mutation_no_requirement(self):
|
||||
r = assess_mutation_capability_proof("Selected PR: #1\nSkipped, no action.")
|
||||
self.assertTrue(r["proven"], r["reasons"])
|
||||
|
||||
|
||||
class TestWiring(unittest.TestCase):
|
||||
def test_review_proofs_wrapper_matches_module(self):
|
||||
self.assertEqual(
|
||||
proofs_assess(MERGE_PROVEN)["proven"],
|
||||
assess_mutation_capability_proof(MERGE_PROVEN)["proven"],
|
||||
)
|
||||
|
||||
def test_final_report_validator_flags_nearby_capability_merge(self):
|
||||
report = """
|
||||
## Controller Handoff
|
||||
- Task: review_pr
|
||||
Review decision: approved
|
||||
Merge result: merged 0123456789ab
|
||||
Mutation capability table:
|
||||
- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review
|
||||
"""
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
rule_ids = [f["rule_id"] for f in result.get("findings", [])]
|
||||
self.assertIn("reviewer.mutation_capability_proof", rule_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import merger_lease_adoption as mla
|
||||
import reviewer_pr_lease as leases
|
||||
|
||||
|
||||
@@ -123,7 +124,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
|
||||
"session_id": "my-session",
|
||||
"candidate_head": head,
|
||||
"target_branch": "master",
|
||||
})
|
||||
"comment_id": 101,
|
||||
}, lease_provenance=mla.build_lease_provenance(
|
||||
source=mla.SOURCE_ACQUIRE,
|
||||
comment_id=101,
|
||||
))
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=382,
|
||||
comments=comments,
|
||||
@@ -143,7 +148,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
|
||||
"pr_number": 382,
|
||||
"session_id": "my-session",
|
||||
"candidate_head": reviewed,
|
||||
})
|
||||
"comment_id": 102,
|
||||
}, lease_provenance=mla.build_lease_provenance(
|
||||
source=mla.SOURCE_ACQUIRE,
|
||||
comment_id=102,
|
||||
))
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=382,
|
||||
comments=comments,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests for root checkout guard (#475)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 # noqa: E402
|
||||
import root_checkout_guard as rcg # noqa: E402
|
||||
|
||||
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
|
||||
MASTER_SHA = "a" * 40
|
||||
OTHER_SHA = "b" * 40
|
||||
|
||||
|
||||
class TestAssessRootCheckoutGuard(unittest.TestCase):
|
||||
def _assess(self, **kwargs):
|
||||
defaults = {
|
||||
"workspace_path": CONTROL_ROOT,
|
||||
"canonical_repo_root": CONTROL_ROOT,
|
||||
"current_branch": "master",
|
||||
"head_sha": MASTER_SHA,
|
||||
"porcelain_status": "",
|
||||
"remote_master_sha": MASTER_SHA,
|
||||
"resolved_role": "author",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return rcg.assess_root_checkout_guard(**defaults)
|
||||
|
||||
def test_clean_master_control_checkout_allowed(self):
|
||||
result = self._assess()
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_branches_worktree_allowed_for_author(self):
|
||||
result = self._assess(
|
||||
workspace_path=BRANCHES_WORKTREE,
|
||||
current_branch="feat/issue-475-root-checkout-guard",
|
||||
head_sha=OTHER_SHA,
|
||||
resolved_role="author",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_branches_worktree_allowed_for_reviewer(self):
|
||||
result = self._assess(
|
||||
workspace_path=f"{CONTROL_ROOT}/branches/review-pr-1",
|
||||
current_branch="review-pr-1",
|
||||
resolved_role="reviewer",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_reconciler_always_allowed(self):
|
||||
result = self._assess(
|
||||
current_branch="feat/some-branch",
|
||||
head_sha=OTHER_SHA,
|
||||
porcelain_status=" M gitea_mcp_server.py\n",
|
||||
resolved_role="reconciler",
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_feature_branch_on_control_checkout_blocked(self):
|
||||
result = self._assess(
|
||||
current_branch="feat/issue-99-example",
|
||||
head_sha=OTHER_SHA,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("not a stable base branch", result["reasons"][0])
|
||||
|
||||
def test_detached_head_blocked(self):
|
||||
result = self._assess(current_branch=None)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("detached HEAD", result["reasons"][0])
|
||||
|
||||
def test_dirty_control_checkout_blocked(self):
|
||||
result = self._assess(porcelain_status=" M gitea_mcp_server.py\n")
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("tracked local edits", result["reasons"][0])
|
||||
|
||||
def test_head_behind_prgs_master_blocked(self):
|
||||
result = self._assess(
|
||||
head_sha=OTHER_SHA,
|
||||
remote_master_sha=MASTER_SHA,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("does not match prgs/master", result["reasons"][0])
|
||||
|
||||
def test_merger_requires_clean_control_checkout(self):
|
||||
result = self._assess(
|
||||
workspace_path=BRANCHES_WORKTREE,
|
||||
current_branch="feat/issue-475-root-checkout-guard",
|
||||
head_sha=OTHER_SHA,
|
||||
resolved_role="merger",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
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._get_workspace_porcelain", return_value="")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
|
||||
@patch("gitea_mcp_server._resolve_author_mutation_context")
|
||||
def test_reviewer_from_contaminated_root_blocked(
|
||||
self, mock_ctx, mock_git, _remote_sha, _porcelain,
|
||||
):
|
||||
srv._preflight_capability_baseline_porcelain = ""
|
||||
mock_ctx.return_value = {
|
||||
"workspace_path": CONTROL_ROOT,
|
||||
"canonical_repo_root": CONTROL_ROOT,
|
||||
"process_project_root": CONTROL_ROOT,
|
||||
}
|
||||
mock_git.return_value = {
|
||||
"current_branch": "feat/hijacked-root",
|
||||
"head_sha": OTHER_SHA,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity("prgs", worktree_path=CONTROL_ROOT)
|
||||
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
|
||||
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
|
||||
|
||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
|
||||
@patch("gitea_mcp_server._resolve_author_mutation_context")
|
||||
def test_reviewer_from_branches_worktree_allowed(
|
||||
self, mock_ctx, mock_git, _remote_sha, _porcelain,
|
||||
):
|
||||
srv._preflight_capability_baseline_porcelain = ""
|
||||
mock_ctx.return_value = {
|
||||
"workspace_path": BRANCHES_WORKTREE,
|
||||
"canonical_repo_root": CONTROL_ROOT,
|
||||
"process_project_root": BRANCHES_WORKTREE,
|
||||
}
|
||||
mock_git.return_value = {
|
||||
"current_branch": "feat/issue-475-root-checkout-guard",
|
||||
"head_sha": OTHER_SHA,
|
||||
"porcelain_status": "",
|
||||
}
|
||||
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Contract checks for the root-level `run-tests.sh` convenience runner (#473).
|
||||
|
||||
`run-tests.sh` is the canonical full-validation entry point. It must invoke the
|
||||
project virtualenv interpreter, forward extra args to pytest, fail closed when
|
||||
the venv Python is missing, and stay repo-local (no network, no lock files).
|
||||
These checks pin that behavior so it cannot silently regress, and confirm the
|
||||
developer testing guide names the runner.
|
||||
"""
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "run-tests.sh"
|
||||
GUIDE = REPO_ROOT / "docs" / "developer-testing-guidelines.md"
|
||||
|
||||
|
||||
def _script_text() -> str:
|
||||
return SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_run_tests_script_exists():
|
||||
assert SCRIPT.is_file(), "run-tests.sh must exist at the repository root"
|
||||
|
||||
|
||||
def test_run_tests_script_is_executable():
|
||||
mode = SCRIPT.stat().st_mode
|
||||
assert mode & stat.S_IXUSR, "run-tests.sh must be executable (chmod +x)"
|
||||
|
||||
|
||||
def test_run_tests_script_has_strict_bash_flags():
|
||||
text = _script_text()
|
||||
assert text.startswith("#!/usr/bin/env bash"), "must use the bash shebang"
|
||||
assert "set -euo pipefail" in text, "must set -euo pipefail"
|
||||
|
||||
|
||||
def test_run_tests_script_uses_venv_pytest_and_forwards_args():
|
||||
text = _script_text()
|
||||
# Resolves the venv interpreter relative to the script's own directory.
|
||||
assert "venv/bin/python" in text, "must use the project virtualenv Python"
|
||||
assert "-m pytest" in text, "must run pytest via the module"
|
||||
assert '"$@"' in text, "must forward extra CLI args to pytest"
|
||||
|
||||
|
||||
def test_run_tests_script_fails_closed_without_venv():
|
||||
text = _script_text()
|
||||
# Missing venv must be an explicit, non-zero-exit error, not a silent
|
||||
# fallback to the wrong interpreter.
|
||||
assert "if [[ ! -x" in text, "must guard on an executable venv Python"
|
||||
assert "exit 1" in text, "must exit non-zero when the venv is missing"
|
||||
assert "ERROR" in text, "must print a clear error message"
|
||||
|
||||
|
||||
def test_run_tests_script_stays_repo_local():
|
||||
text = _script_text()
|
||||
# No network calls, no lock-file writes from the runner itself.
|
||||
for forbidden in ("curl", "wget", "gitea_issue_lock.json"):
|
||||
assert forbidden not in text, f"runner must not reference {forbidden!r}"
|
||||
|
||||
|
||||
def test_guide_names_canonical_runner():
|
||||
text = " ".join(GUIDE.read_text(encoding="utf-8").split())
|
||||
assert "./run-tests.sh" in text, "testing guide must name ./run-tests.sh"
|
||||
assert "./run-tests.sh tests/test_mcp_server.py -q" in text, (
|
||||
"testing guide must show the focused-validation example"
|
||||
)
|
||||
@@ -36,10 +36,13 @@ def _lock(mutations=None, correction=False):
|
||||
|
||||
|
||||
def _seed(mutations=None, correction=False):
|
||||
import review_workflow_load
|
||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||
mcp_server._save_review_decision_lock(_lock(mutations, correction))
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
|
||||
|
||||
APPROVED_A = {"pr_number": 5, "action": "approve", "review_id": 1,
|
||||
"review_state": "approve"}
|
||||
RC_A = {"pr_number": 5, "action": "request_changes", "review_id": 2,
|
||||
@@ -94,6 +97,8 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
|
||||
class TestMergeHardStopWiring(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
import review_workflow_load
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
@@ -116,6 +121,8 @@ class TestMergeHardStopWiring(unittest.TestCase):
|
||||
|
||||
class TestMarkFinalHardStopWiring(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
import review_workflow_load
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
@@ -149,6 +156,8 @@ def _mark(action, pr_number=6, **kwargs):
|
||||
|
||||
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
import review_workflow_load
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
|
||||
@@ -126,17 +126,37 @@ class TestRuntimeContextGuardAlignment(unittest.TestCase):
|
||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
||||
srv.verify_preflight_purity(worktree_path=BRANCHES_WORKTREE)
|
||||
|
||||
def test_stable_checkout_still_rejected(self):
|
||||
@mock.patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@mock.patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": "a" * 40,
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
def test_stable_checkout_still_rejected(self, _git, _remote_sha):
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity()
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
|
||||
@mock.patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40)
|
||||
@mock.patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"head_sha": "a" * 40,
|
||||
"porcelain_status": "",
|
||||
},
|
||||
)
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
@mock.patch("subprocess.run")
|
||||
def test_non_branches_worktree_rejected(self, mock_run, *_exists):
|
||||
def test_non_branches_worktree_rejected(
|
||||
self, mock_run, mock_exists, mock_isdir, _git, _remote_sha,
|
||||
):
|
||||
outside = "/tmp/outside-repo-checkout"
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Tests for session-owned worktree cleanup audit, TTL, and integrity (#401, #404)."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import worktree_cleanup_audit as wca # noqa: E402
|
||||
|
||||
|
||||
NOW = datetime(2026, 7, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class TestWorkflowTypeInference(unittest.TestCase):
|
||||
def test_review_pr_path(self):
|
||||
self.assertEqual(
|
||||
wca.infer_workflow_type("branches/review-pr376", "review-pr376"),
|
||||
wca.WORKFLOW_REVIEW,
|
||||
)
|
||||
|
||||
def test_baseline_path(self):
|
||||
self.assertEqual(
|
||||
wca.infer_workflow_type("branches/baseline-master-issue-401"),
|
||||
wca.WORKFLOW_BASELINE,
|
||||
)
|
||||
|
||||
def test_merge_simulation_path(self):
|
||||
self.assertEqual(
|
||||
wca.infer_workflow_type("branches/merge-sim-pr380"),
|
||||
wca.WORKFLOW_MERGE_SIMULATION,
|
||||
)
|
||||
|
||||
def test_issue_work_branch(self):
|
||||
self.assertEqual(
|
||||
wca.infer_workflow_type(
|
||||
"branches/issue-401-worktree", "feat/issue-401-worktree"
|
||||
),
|
||||
wca.WORKFLOW_ISSUE_WORK,
|
||||
)
|
||||
|
||||
def test_conflict_fix_path(self):
|
||||
self.assertEqual(
|
||||
wca.infer_workflow_type("branches/conflict-fix-pr376"),
|
||||
wca.WORKFLOW_CONFLICT_FIX,
|
||||
)
|
||||
|
||||
def test_unknown_path(self):
|
||||
self.assertEqual(
|
||||
wca.infer_workflow_type("branches/scratchpad"), wca.WORKFLOW_UNKNOWN
|
||||
)
|
||||
|
||||
|
||||
class TestMetadata(unittest.TestCase):
|
||||
def test_metadata_fields_present(self):
|
||||
meta = wca.build_worktree_metadata(
|
||||
path="branches/issue-401-worktree",
|
||||
branch="feat/issue-401-worktree",
|
||||
head_sha="abc123",
|
||||
creator="jcwalker3",
|
||||
profile="prgs-author",
|
||||
created_at=_iso(NOW),
|
||||
last_used_at=_iso(NOW),
|
||||
)
|
||||
for field in (
|
||||
"path",
|
||||
"workflow_type",
|
||||
"issue_number",
|
||||
"pr_number",
|
||||
"branch",
|
||||
"head_sha",
|
||||
"creator",
|
||||
"profile",
|
||||
"created_at",
|
||||
"last_used_at",
|
||||
"cleanup_eligibility",
|
||||
):
|
||||
self.assertIn(field, meta)
|
||||
self.assertEqual(meta["issue_number"], 401)
|
||||
self.assertEqual(meta["workflow_type"], wca.WORKFLOW_ISSUE_WORK)
|
||||
|
||||
def test_review_metadata_auto_removable_flag(self):
|
||||
meta = wca.build_worktree_metadata(path="branches/review-pr42")
|
||||
self.assertTrue(meta["auto_remove_on_success"])
|
||||
|
||||
|
||||
class TestTTL(unittest.TestCase):
|
||||
def test_expired(self):
|
||||
old = _iso(NOW - timedelta(hours=48))
|
||||
self.assertTrue(wca.is_ttl_expired(last_used_at=old, now=NOW, ttl_hours=24))
|
||||
|
||||
def test_not_expired(self):
|
||||
recent = _iso(NOW - timedelta(hours=1))
|
||||
self.assertFalse(
|
||||
wca.is_ttl_expired(last_used_at=recent, now=NOW, ttl_hours=24)
|
||||
)
|
||||
|
||||
def test_unknown_timestamp_fails_safe(self):
|
||||
self.assertFalse(wca.is_ttl_expired(last_used_at=None, now=NOW))
|
||||
self.assertFalse(
|
||||
wca.is_ttl_expired(last_used_at="not-a-date", now=NOW)
|
||||
)
|
||||
|
||||
|
||||
class TestClassification(unittest.TestCase):
|
||||
def test_successful_review_cleanup(self):
|
||||
# Scenario 1: clean review worktree -> removable.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_REVIEW, is_dirty=False
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE)
|
||||
self.assertTrue(wca.is_removable(cls))
|
||||
|
||||
def test_dirty_worktree_preserved(self):
|
||||
# Scenario 3: dirty worktree is never removable.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_REVIEW, is_dirty=True
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_DIRTY_LOCAL)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
def test_active_pr_worktree_preserved(self):
|
||||
# Scenario 4: open PR wins over an otherwise-removable review worktree.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_REVIEW,
|
||||
is_dirty=False,
|
||||
has_open_pr=True,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_ACTIVE_OPEN_PR)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
def test_stale_clean_issue_worktree_removable(self):
|
||||
# Scenario 5: clean issue worktree, TTL expired, no lock -> removable.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_ISSUE_WORK,
|
||||
is_dirty=False,
|
||||
ttl_expired=True,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE)
|
||||
self.assertTrue(wca.is_removable(cls))
|
||||
|
||||
def test_fresh_clean_issue_worktree_preserved(self):
|
||||
# Clean issue worktree not yet TTL-expired stays active.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_ISSUE_WORK,
|
||||
is_dirty=False,
|
||||
ttl_expired=False,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
def test_detached_review_worktree_classified(self):
|
||||
# Scenario 6: detached review worktree -> detached_review_leftover.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_REVIEW,
|
||||
is_dirty=False,
|
||||
is_detached=True,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_DETACHED_REVIEW_LEFTOVER)
|
||||
self.assertTrue(wca.is_removable(cls))
|
||||
|
||||
def test_ttl_expired_but_dirty_preserved(self):
|
||||
# Scenario 7: dirty wins over TTL expiry.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_ISSUE_WORK,
|
||||
is_dirty=True,
|
||||
ttl_expired=True,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_DIRTY_LOCAL)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
def test_lease_protected_worktree_preserved(self):
|
||||
# Scenario 8: active lease is never removable.
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_REVIEW,
|
||||
is_dirty=False,
|
||||
has_active_lease=True,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
def test_active_issue_lock_preserved(self):
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_ISSUE_WORK,
|
||||
is_dirty=False,
|
||||
has_active_issue_lock=True,
|
||||
ttl_expired=True,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
def test_protected_base_worktree_never_removable(self):
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_ISSUE_WORK,
|
||||
is_dirty=False,
|
||||
is_protected=True,
|
||||
ttl_expired=True,
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_UNSAFE_UNKNOWN)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
def test_unknown_workflow_type_unsafe(self):
|
||||
cls = wca.classify_worktree(
|
||||
workflow_type=wca.WORKFLOW_UNKNOWN, is_dirty=False, ttl_expired=True
|
||||
)
|
||||
self.assertEqual(cls, wca.CLASS_UNSAFE_UNKNOWN)
|
||||
self.assertFalse(wca.is_removable(cls))
|
||||
|
||||
|
||||
class TestRemovalDecision(unittest.TestCase):
|
||||
def test_safe_removal_proof(self):
|
||||
decision = wca.assess_worktree_removal(
|
||||
path="branches/review-pr42",
|
||||
branch="review-pr42",
|
||||
head_sha="abc123",
|
||||
is_dirty=False,
|
||||
has_open_pr=False,
|
||||
has_active_lease=False,
|
||||
classification=wca.CLASS_CLEAN_STALE_REMOVABLE,
|
||||
)
|
||||
self.assertTrue(decision["safe_to_remove"])
|
||||
self.assertEqual(decision["block_reasons"], [])
|
||||
self.assertTrue(decision["clean"])
|
||||
self.assertTrue(decision["no_active_pr"])
|
||||
self.assertTrue(decision["no_active_lease"])
|
||||
|
||||
def test_dirty_blocks_removal(self):
|
||||
decision = wca.assess_worktree_removal(
|
||||
path="branches/review-pr42",
|
||||
branch="review-pr42",
|
||||
head_sha="abc123",
|
||||
is_dirty=True,
|
||||
has_open_pr=False,
|
||||
has_active_lease=False,
|
||||
classification=wca.CLASS_DIRTY_LOCAL,
|
||||
)
|
||||
self.assertFalse(decision["safe_to_remove"])
|
||||
self.assertIn("worktree has uncommitted changes", decision["block_reasons"])
|
||||
|
||||
def test_open_pr_and_lease_block_removal(self):
|
||||
decision = wca.assess_worktree_removal(
|
||||
path="branches/review-pr42",
|
||||
branch="review-pr42",
|
||||
head_sha="abc123",
|
||||
is_dirty=False,
|
||||
has_open_pr=True,
|
||||
has_active_lease=True,
|
||||
classification=wca.CLASS_ACTIVE_OPEN_PR,
|
||||
)
|
||||
self.assertFalse(decision["safe_to_remove"])
|
||||
self.assertIn("worktree branch has an open PR", decision["block_reasons"])
|
||||
self.assertIn("worktree has an active lease", decision["block_reasons"])
|
||||
|
||||
|
||||
class TestSuccessCleanupPlan(unittest.TestCase):
|
||||
def test_review_worktree_removed_at_success(self):
|
||||
# Scenario 1 end-to-end: clean review worktree removed at success.
|
||||
meta = wca.build_worktree_metadata(path="branches/review-pr42")
|
||||
plan = wca.plan_success_cleanup(
|
||||
metadata=meta, is_dirty=False, has_open_pr=False, has_active_lease=False
|
||||
)
|
||||
self.assertTrue(plan["remove"])
|
||||
|
||||
def test_failed_review_leaves_worktree_reported(self):
|
||||
# Scenario 2: dirty review worktree preserved and reported at failure.
|
||||
meta = wca.build_worktree_metadata(path="branches/review-pr42")
|
||||
plan = wca.plan_success_cleanup(
|
||||
metadata=meta, is_dirty=True, has_open_pr=False, has_active_lease=False
|
||||
)
|
||||
self.assertFalse(plan["remove"])
|
||||
self.assertIn("uncommitted", plan["reason"])
|
||||
report = wca.cleanup_failure_report(meta["path"], plan["reason"])
|
||||
self.assertFalse(report["removed"])
|
||||
self.assertEqual(report["path"], "branches/review-pr42")
|
||||
|
||||
def test_issue_worktree_preserved_by_policy(self):
|
||||
meta = wca.build_worktree_metadata(
|
||||
path="branches/issue-401-worktree", branch="feat/issue-401-worktree"
|
||||
)
|
||||
plan = wca.plan_success_cleanup(
|
||||
metadata=meta, is_dirty=False, has_open_pr=False, has_active_lease=False
|
||||
)
|
||||
self.assertFalse(plan["remove"])
|
||||
self.assertIn("preserved by policy", plan["reason"])
|
||||
|
||||
|
||||
class TestPorcelainParser(unittest.TestCase):
|
||||
def test_parse_branch_and_detached(self):
|
||||
text = (
|
||||
"worktree /repo\n"
|
||||
"HEAD 1111111111111111111111111111111111111111\n"
|
||||
"branch refs/heads/master\n"
|
||||
"\n"
|
||||
"worktree /repo/branches/review-pr42\n"
|
||||
"HEAD 2222222222222222222222222222222222222222\n"
|
||||
"detached\n"
|
||||
)
|
||||
entries = wca.parse_worktree_porcelain(text)
|
||||
self.assertEqual(len(entries), 2)
|
||||
self.assertEqual(entries[0]["branch"], "master")
|
||||
self.assertFalse(entries[0]["detached"])
|
||||
self.assertIsNone(entries[1]["branch"])
|
||||
self.assertTrue(entries[1]["detached"])
|
||||
|
||||
|
||||
class TestAuditReportAccuracy(unittest.TestCase):
|
||||
"""Scenario 9: cleanup report accuracy over a mixed branches/ directory."""
|
||||
|
||||
PORCELAIN = (
|
||||
"worktree /repo\n"
|
||||
"HEAD 1111111111111111111111111111111111111111\n"
|
||||
"branch refs/heads/master\n"
|
||||
"\n"
|
||||
"worktree /repo/branches/review-pr42\n"
|
||||
"HEAD 2222222222222222222222222222222222222222\n"
|
||||
"branch refs/heads/review-pr42\n"
|
||||
"\n"
|
||||
"worktree /repo/branches/issue-400-open-pr\n"
|
||||
"HEAD 3333333333333333333333333333333333333333\n"
|
||||
"branch refs/heads/feat/issue-400-open-pr\n"
|
||||
"\n"
|
||||
"worktree /repo/branches/issue-401-dirty\n"
|
||||
"HEAD 4444444444444444444444444444444444444444\n"
|
||||
"branch refs/heads/feat/issue-401-dirty\n"
|
||||
)
|
||||
|
||||
def _fake_dirty(self, path):
|
||||
if path.endswith("issue-401-dirty"):
|
||||
return {"exists": True, "dirty": True, "dirty_files": [" M x.py"]}
|
||||
return {"exists": True, "dirty": False, "dirty_files": []}
|
||||
|
||||
def test_mixed_directory_classified(self):
|
||||
with patch.object(
|
||||
wca,
|
||||
"list_worktrees",
|
||||
return_value=wca.parse_worktree_porcelain(self.PORCELAIN),
|
||||
), patch.object(
|
||||
wca, "read_worktree_dirty", side_effect=self._fake_dirty
|
||||
), patch.object(
|
||||
wca, "git_worktree_list", return_value="(mocked)"
|
||||
):
|
||||
report = wca.audit_branches_directory(
|
||||
"/repo",
|
||||
open_pr_branches={"feat/issue-400-open-pr"},
|
||||
)
|
||||
|
||||
by_path = {wt["path"]: wt for wt in report["worktrees"]}
|
||||
# main checkout on master -> protected -> unsafe/unknown, not removable
|
||||
self.assertEqual(
|
||||
by_path["/repo"]["classification"], wca.CLASS_UNSAFE_UNKNOWN
|
||||
)
|
||||
# clean review worktree -> removable
|
||||
self.assertEqual(
|
||||
by_path["/repo/branches/review-pr42"]["classification"],
|
||||
wca.CLASS_CLEAN_STALE_REMOVABLE,
|
||||
)
|
||||
# open PR branch -> preserved
|
||||
self.assertEqual(
|
||||
by_path["/repo/branches/issue-400-open-pr"]["classification"],
|
||||
wca.CLASS_ACTIVE_OPEN_PR,
|
||||
)
|
||||
# dirty worktree -> preserved
|
||||
self.assertEqual(
|
||||
by_path["/repo/branches/issue-401-dirty"]["classification"],
|
||||
wca.CLASS_DIRTY_LOCAL,
|
||||
)
|
||||
# exactly one removable candidate (the clean review worktree)
|
||||
self.assertEqual(report["removable_count"], 1)
|
||||
self.assertEqual(
|
||||
report["removable_candidates"][0]["path"],
|
||||
"/repo/branches/review-pr42",
|
||||
)
|
||||
self.assertEqual(report["total"], 4)
|
||||
self.assertEqual(report["git_worktree_list"], "(mocked)")
|
||||
|
||||
|
||||
def _integrity_entry(
|
||||
path: str,
|
||||
*,
|
||||
classification: str,
|
||||
registered: bool = True,
|
||||
preserve: bool | None = None,
|
||||
) -> dict:
|
||||
preserve_flag = preserve if preserve is not None else classification in {
|
||||
"active_open_pr",
|
||||
"active_issue_work",
|
||||
"dirty_local_worktree",
|
||||
"unsafe_unknown",
|
||||
}
|
||||
return {
|
||||
"path": path,
|
||||
"classification": classification,
|
||||
"preserve": preserve_flag,
|
||||
"registered_worktree": registered,
|
||||
"worktree_state": {"exists": True, "clean": classification == "clean_stale_removable"},
|
||||
"worktree_record": {"branch": "feat/x"} if registered else None,
|
||||
}
|
||||
|
||||
|
||||
def _integrity_snapshot(entries: list[dict]) -> dict:
|
||||
return {"entries": entries}
|
||||
|
||||
|
||||
class TestParseWorktreePorcelain(unittest.TestCase):
|
||||
def test_parses_multiple_worktrees(self):
|
||||
text = "\n".join([
|
||||
"worktree /proj/branches/foo",
|
||||
"HEAD abcdef0123456789abcdef0123456789abcdef0",
|
||||
"branch refs/heads/feat/foo",
|
||||
"",
|
||||
"worktree /proj",
|
||||
"HEAD 1111111111111111111111111111111111111111",
|
||||
"branch refs/heads/master",
|
||||
])
|
||||
parsed = wca.parse_worktree_list_porcelain(text)
|
||||
self.assertEqual(len(parsed), 2)
|
||||
self.assertEqual(parsed[0]["branch"], "feat/foo")
|
||||
|
||||
|
||||
class TestClassifyEntry(unittest.TestCase):
|
||||
def test_active_pr_classification(self):
|
||||
result = wca.classify_branches_entry(
|
||||
rel_path="branches/feat-issue-1-x",
|
||||
worktree_record={"branch": "feat/issue-1-x"},
|
||||
worktree_state={"exists": True, "clean": True, "dirty_files": []},
|
||||
open_pr_branches={"feat/issue-1-x"},
|
||||
)
|
||||
self.assertEqual(result, "active_open_pr")
|
||||
|
||||
def test_dirty_classification(self):
|
||||
result = wca.classify_branches_entry(
|
||||
rel_path="branches/dirty-one",
|
||||
worktree_record={"branch": "feat/dirty-one"},
|
||||
worktree_state={"exists": True, "dirty_files": ["a.py"]},
|
||||
open_pr_branches=set(),
|
||||
)
|
||||
self.assertEqual(result, "dirty_local_worktree")
|
||||
|
||||
|
||||
class TestCleanupIntegrity(unittest.TestCase):
|
||||
def test_preserved_worktree_remains(self):
|
||||
path = "branches/keep-me"
|
||||
before = _integrity_snapshot([
|
||||
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
|
||||
])
|
||||
after = _integrity_snapshot([
|
||||
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
|
||||
])
|
||||
result = wca.assess_worktree_cleanup_integrity(before=before, after=after)
|
||||
self.assertTrue(result["integrity_passed"])
|
||||
|
||||
def test_intentional_removal_passes(self):
|
||||
path = "branches/remove-me"
|
||||
before = _integrity_snapshot([
|
||||
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
|
||||
])
|
||||
after = _integrity_snapshot([])
|
||||
result = wca.assess_worktree_cleanup_integrity(
|
||||
before=before,
|
||||
after=after,
|
||||
removals=[{
|
||||
"path": path,
|
||||
"method": "git worktree remove",
|
||||
"pre_removal_proof": "clean status",
|
||||
}],
|
||||
)
|
||||
self.assertTrue(result["integrity_passed"])
|
||||
|
||||
def test_dirty_worktree_disappears_fails(self):
|
||||
path = "branches/dirty-wt"
|
||||
before = _integrity_snapshot([_integrity_entry(path, classification="dirty_local_worktree")])
|
||||
after = _integrity_snapshot([])
|
||||
recon = wca.reconcile_cleanup_audit(before, after)
|
||||
result = wca.assess_cleanup_audit_integrity(recon)
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_active_pr_worktree_disappears_fails(self):
|
||||
path = "branches/review-pr99"
|
||||
before = _integrity_snapshot([_integrity_entry(path, classification="active_open_pr")])
|
||||
after = _integrity_snapshot([])
|
||||
result = wca.assess_worktree_cleanup_integrity(before=before, after=after)
|
||||
self.assertFalse(result["integrity_passed"])
|
||||
|
||||
def test_explained_missing_allowed(self):
|
||||
path = "branches/review-pr382"
|
||||
before = _integrity_snapshot([
|
||||
_integrity_entry(path, classification="detached_review_leftover", preserve=False),
|
||||
])
|
||||
after = _integrity_snapshot([])
|
||||
result = wca.assess_worktree_cleanup_integrity(
|
||||
before=before,
|
||||
after=after,
|
||||
explained_missing={path: "removed concurrently by sibling session"},
|
||||
)
|
||||
self.assertTrue(result["integrity_passed"])
|
||||
|
||||
def test_removal_log_omits_clean_stale_fails(self):
|
||||
path = "branches/a"
|
||||
before = _integrity_snapshot([
|
||||
_integrity_entry(path, classification="clean_stale_removable", preserve=False),
|
||||
])
|
||||
after = _integrity_snapshot([])
|
||||
recon = wca.reconcile_cleanup_audit(before, after, removal_log=[])
|
||||
self.assertFalse(recon["removal_log_complete"])
|
||||
|
||||
|
||||
class TestCleanupReportProof(unittest.TestCase):
|
||||
def test_complete_report_passes(self):
|
||||
report = "\n".join([
|
||||
"Cleanup audit reconciliation table:",
|
||||
"Initial count: 10",
|
||||
"Removed count: 3",
|
||||
"Preserved count: 7",
|
||||
"Missing-unexplained count: 0",
|
||||
"Final count: 7",
|
||||
"Final verification: git worktree list proof attached",
|
||||
])
|
||||
result = wca.assess_cleanup_audit_final_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_incomplete_report_fails(self):
|
||||
result = wca.assess_cleanup_audit_final_report("removed some worktrees")
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user