Merge master into fix/issue-714-session-context-immutability
Resolve gitea_mcp_server.py conflicts after master advanced with #724 (side-effect-free resolver), #725 (merger lease), #728 (PR-sync), and #702 stale-binding recovery: - Keep #714 fail-closed session immutability: no auto profile switch (_ensure_matching_profile / _try_auto_switch_for_operation). - Retain master #709 _authenticated_actor identity helper. - Preserve resolve-path auto_recover=False (#685) and report-only stale_binding_recovery coexistence with runtime_reconnect_required. - Cross-host / cross-repository substitution still fails closed without mutating the pinned session context. - Regression coverage for merge coexistence, dual-module identity-cache isolation in conftest, and structured unknown_task fail-closed. Closes #714 conflict remediation path for PR #715.
This commit is contained in:
@@ -99,6 +99,20 @@ def _reset_mutation_authority(monkeypatch):
|
||||
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||
# #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py
|
||||
# into its own globals, so `import gitea_mcp_server` is a separate module
|
||||
# object with its own identity caches; leaving it dirty leaks login across
|
||||
# tests that import the implementation module directly.
|
||||
try:
|
||||
import gitea_mcp_server as _gitea_impl
|
||||
|
||||
monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {})
|
||||
if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"):
|
||||
monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {})
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"):
|
||||
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
|
||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
||||
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,41 +79,46 @@ class TestApiRequestFailures(unittest.TestCase):
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
||||
mock_open.side_effect = TimeoutError("timed out")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||
# Fixed message only (#699) — still a RuntimeError subclass.
|
||||
self.assertIsInstance(ctx.exception, RuntimeError)
|
||||
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_dns_network_failure_converted(self, mock_open):
|
||||
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_502_upstream_message(self, mock_open):
|
||||
mock_open.side_effect = http_error(502, "bad gateway")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("HTTP 502", msg)
|
||||
self.assertIn("upstream unavailable", msg)
|
||||
self.assertEqual(msg, "Gitea upstream unavailable")
|
||||
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
|
||||
self.assertEqual(ctx.exception.http_status, 502)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_503_upstream_message(self, mock_open):
|
||||
mock_open.side_effect = http_error(503, "")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("HTTP 503", str(ctx.exception))
|
||||
self.assertIn("upstream unavailable", str(ctx.exception))
|
||||
self.assertEqual(str(ctx.exception), "Gitea upstream unavailable")
|
||||
self.assertEqual(ctx.exception.http_status, 503)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
||||
# Non-JSON garbage error body must still yield a clean RuntimeError.
|
||||
# Non-JSON garbage error body must still yield a clean typed error
|
||||
# with a fixed message (no body echo — #699).
|
||||
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("HTTP 500", str(ctx.exception))
|
||||
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||
self.assertNotIn("<html>", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
||||
@@ -126,16 +131,17 @@ class TestApiRequestFailures(unittest.TestCase):
|
||||
def test_no_secret_leak_in_error_body(self, mock_open):
|
||||
mock_open.side_effect = http_error(
|
||||
400, "failed: token supersecret123 rejected")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
msg = str(ctx.exception)
|
||||
self.assertNotIn("supersecret123", msg)
|
||||
self.assertIn(gitea_audit.REDACTED, msg)
|
||||
# Fixed message — body never appears (stronger than redaction).
|
||||
self.assertEqual(msg, "Gitea HTTP request failed")
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_auth_header_never_in_error(self, mock_open):
|
||||
mock_open.side_effect = http_error(400, "bad request")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||
|
||||
|
||||
@@ -27,7 +27,13 @@ from task_capability_map import required_permission, required_role
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "prgs-author-delete",
|
||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "prgs-author-delete",
|
||||
}
|
||||
|
||||
+1106
-27
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,10 @@ HEAD_C = "c" * 40
|
||||
|
||||
|
||||
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
||||
mutations = list(mutations or [])
|
||||
corr_pr = None
|
||||
if correction and mutations:
|
||||
corr_pr = mutations[-1].get("pr_number")
|
||||
return {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
@@ -40,9 +44,11 @@ def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, read
|
||||
"ready_remote": "prgs" if ready_pr else None,
|
||||
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
||||
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
||||
"live_mutations": list(mutations or []),
|
||||
"live_mutations": mutations,
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": None,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": corr_pr,
|
||||
"correction_head_sha": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -174,12 +180,13 @@ class TestHardStopHeadScope(unittest.TestCase):
|
||||
self.assertTrue(reasons)
|
||||
self.assertIn("#332", reasons[0])
|
||||
|
||||
def test_rc_head_a_blocks_other_pr(self):
|
||||
def test_rc_head_a_allows_other_pr_via_cross_pr_isolation(self):
|
||||
"""#693: foreign-PR terminal must not hard-stop mark_ready on another PR."""
|
||||
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||
700, "mark_ready", expected_head_sha=HEAD_B
|
||||
)
|
||||
self.assertTrue(reasons)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_legacy_619_ledger_allows_new_head(self):
|
||||
"""PR #619-style durable lock without mutation head_sha."""
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""#685: gitea_resolve_task_capability must be side-effect free.
|
||||
|
||||
Stale-runtime detection remains fail-closed, but the resolver must never:
|
||||
* touch mcp_config.json (or any MCP client config)
|
||||
* spawn recovery threads
|
||||
* call os._exit / terminate the serving process
|
||||
* claim that an auto-restart was triggered
|
||||
|
||||
Recovery is owned by the IDE/client reconnect path only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import sys
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
|
||||
ROLE_PROFILES = (
|
||||
("create_issue", "prgs-author", "author"),
|
||||
("review_pr", "prgs-reviewer", "reviewer"),
|
||||
("merge_pr", "prgs-merger", "merger"),
|
||||
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
|
||||
)
|
||||
|
||||
|
||||
def _stale_self_ps_mocks(profile: str = "prgs-author"):
|
||||
"""Build subprocess mocks: self PID is stale vs code mtime."""
|
||||
mock_getpid = MagicMock(return_value=12345)
|
||||
mock_exists = MagicMock(return_value=True)
|
||||
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||
mock_getmtime = MagicMock(return_value=code_time.timestamp())
|
||||
|
||||
ps_output = (
|
||||
" PID LSTART COMMAND\n"
|
||||
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||
)
|
||||
mock_run_ps = MagicMock()
|
||||
mock_run_ps.stdout = ps_output
|
||||
|
||||
mock_run_env = MagicMock()
|
||||
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
|
||||
|
||||
mock_run_git = MagicMock()
|
||||
mock_run_git.stdout = "SAME"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if args[0] == "ps" and "eww" in args:
|
||||
return mock_run_env
|
||||
if args[0] == "ps":
|
||||
return mock_run_ps
|
||||
if args[0] == "git":
|
||||
return mock_run_git
|
||||
raise ValueError(f"Unexpected subprocess args: {args}")
|
||||
|
||||
mock_run = MagicMock(side_effect=side_effect)
|
||||
return mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
|
||||
|
||||
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
@patch("threading.Thread")
|
||||
@patch("os._exit")
|
||||
def test_stale_self_does_not_touch_config_or_exit(
|
||||
self,
|
||||
mock_exit,
|
||||
mock_thread,
|
||||
mock_utime,
|
||||
mock_getpid,
|
||||
mock_exists,
|
||||
mock_getmtime,
|
||||
mock_run,
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
before_threads = threading.active_count()
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
after_threads = threading.active_count()
|
||||
|
||||
self.assertTrue(
|
||||
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
|
||||
for r in reasons),
|
||||
reasons,
|
||||
)
|
||||
# Must not claim auto-restart / config touch
|
||||
blob = " ".join(reasons)
|
||||
self.assertNotIn("Auto-restart has been triggered", blob)
|
||||
self.assertNotIn("touched mcp_config", blob)
|
||||
self.assertNotIn("will cleanly exit", blob)
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
self.assertEqual(before_threads, after_threads)
|
||||
|
||||
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||
@patch("subprocess.run")
|
||||
@patch("os.path.getmtime")
|
||||
@patch("os.path.exists")
|
||||
@patch("os.getpid")
|
||||
@patch("os.utime")
|
||||
def test_repeated_stale_calls_do_not_trigger_restart_loop(
|
||||
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||
):
|
||||
mock_getpid.return_value = 12345
|
||||
mock_exists.return_value = True
|
||||
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||
|
||||
for _ in range(5):
|
||||
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||
"create_issue", ["prgs-author"]
|
||||
)
|
||||
self.assertTrue(any("stale-runtime" in r for r in reasons))
|
||||
|
||||
mock_utime.assert_not_called()
|
||||
|
||||
def test_trigger_mcp_auto_restart_removed(self):
|
||||
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
|
||||
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
|
||||
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.close",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": profile_name,
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"role": role,
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
profile_name
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": profile_name,
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||
return result, mock_utime, mock_thread, mock_exit
|
||||
|
||||
def test_stale_returns_typed_blocker_fields(self):
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
self.assertTrue(result.get("stop_required"), result)
|
||||
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
|
||||
self.assertIs(result.get("mutation_performed"), False)
|
||||
action = result.get("exact_safe_next_action") or ""
|
||||
self.assertIn("reconnect", action.lower())
|
||||
self.assertNotIn("None; ready for operations", action)
|
||||
reason = result.get("reason") or ""
|
||||
self.assertIn("stale-runtime", reason)
|
||||
self.assertNotIn("Auto-restart has been triggered", reason)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
|
||||
for task, profile, role in ROLE_PROFILES:
|
||||
with self.subTest(task=task, profile=profile):
|
||||
# Skip tasks that may be unknown on this branch
|
||||
try:
|
||||
import task_capability_map as tcm
|
||||
|
||||
tcm.required_permission(task)
|
||||
except Exception:
|
||||
self.skipTest(f"task {task} not in capability map")
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = (
|
||||
self._resolve_with_stale_runtime(task, profile, role)
|
||||
)
|
||||
self.assertTrue(
|
||||
result.get("restart_required") or result.get("stop_required"),
|
||||
result,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
def test_config_mtime_and_contents_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = os.path.join(tmp, "mcp_config.json")
|
||||
original = '{"servers": {"gitea-author": {}}}'
|
||||
with open(cfg, "w", encoding="utf-8") as fh:
|
||||
fh.write(original)
|
||||
mtime_before = os.path.getmtime(cfg)
|
||||
|
||||
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||
"create_issue", "prgs-author", "author"
|
||||
)
|
||||
# Force-path also must not use real utime when diagnostics runs
|
||||
with open(cfg, encoding="utf-8") as fh:
|
||||
after = fh.read()
|
||||
self.assertEqual(after, original)
|
||||
self.assertEqual(os.path.getmtime(cfg), mtime_before)
|
||||
mock_utime.assert_not_called()
|
||||
self.assertTrue(result.get("restart_required"), result)
|
||||
|
||||
|
||||
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
|
||||
def test_parity_stale_still_reports_restart_required(self):
|
||||
"""Mutation-facing parity gate remains fail-closed when heads differ."""
|
||||
import master_parity_gate as mpg
|
||||
|
||||
out = mpg.assess_master_parity(
|
||||
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
)
|
||||
self.assertFalse(out.get("in_parity"))
|
||||
self.assertTrue(out.get("restart_required") or out.get("stale"))
|
||||
|
||||
|
||||
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
|
||||
def test_resolve_docstring_declares_side_effect_free(self):
|
||||
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
|
||||
lower = doc.lower()
|
||||
self.assertTrue(
|
||||
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
|
||||
doc,
|
||||
)
|
||||
self.assertNotIn("auto-restart", lower)
|
||||
self.assertNotIn("os._exit", lower)
|
||||
|
||||
|
||||
class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase):
|
||||
"""After merging master: #685 reconnect blocker + #702 report-only binding."""
|
||||
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
def test_resolve_uses_report_only_stale_binding_assessment(self):
|
||||
"""Capability resolve must call stale-binding assess with auto_recover=False."""
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.branch.push",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.merge",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
config = {
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||
"prgs-author"
|
||||
)
|
||||
fake_binding = {
|
||||
"classification": "missing_path",
|
||||
"active_worktree": "/tmp/gone",
|
||||
"reasons": ["path missing"],
|
||||
}
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
},
|
||||
clear=False,
|
||||
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="test-user"
|
||||
), patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"_assess_stale_active_binding",
|
||||
return_value=fake_binding,
|
||||
) as mock_assess, patch(
|
||||
"subprocess.run", mock_run
|
||||
), patch(
|
||||
"os.path.getmtime", mock_getmtime
|
||||
), patch(
|
||||
"os.path.exists", mock_exists
|
||||
), patch(
|
||||
"os.getpid", mock_getpid
|
||||
), patch(
|
||||
"os.utime"
|
||||
) as mock_utime, patch(
|
||||
"threading.Thread"
|
||||
) as mock_thread, patch(
|
||||
"os._exit"
|
||||
) as mock_exit:
|
||||
result = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
|
||||
mock_assess.assert_called()
|
||||
# Every call must be report-only (no env clear / session-state write).
|
||||
for call in mock_assess.call_args_list:
|
||||
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||
self.assertEqual(
|
||||
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||
)
|
||||
self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result)
|
||||
self.assertIs(result.get("mutation_performed"), False, result)
|
||||
mock_utime.assert_not_called()
|
||||
mock_thread.assert_not_called()
|
||||
mock_exit.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -561,12 +561,29 @@ class TestIssue691SupersededHeadCleanup(unittest.TestCase):
|
||||
self.assertTrue(any("owns the lease" in r for r in result["reasons"]))
|
||||
|
||||
def test_superseded_without_terminal_review_denied(self):
|
||||
# Pre-expiry the missing terminal review is ambiguous (fail closed);
|
||||
# post-expiry with unknown owner evidence it is a crash-orphan (#702)
|
||||
# that still fails closed until owner-process exit is proven.
|
||||
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||
result = _assess([claim], formal_reviews=[], now=now)
|
||||
result = _assess(
|
||||
[claim], formal_reviews=[], now=now, owner_process_alive=None
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
|
||||
self.assertEqual(
|
||||
result["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
|
||||
fresh_expires = now + timedelta(minutes=60)
|
||||
fresh = _lease_comment(
|
||||
expires_at=fresh_expires, last_activity=now - timedelta(minutes=5)
|
||||
)
|
||||
pre_expiry = _assess([fresh], formal_reviews=[], now=now)
|
||||
self.assertFalse(pre_expiry["cleanup_allowed"])
|
||||
self.assertEqual(
|
||||
pre_expiry["classification"], "ambiguous_conflicting_evidence"
|
||||
)
|
||||
|
||||
|
||||
class TestIssue691DiagnoseClassifications(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Review-decision-lock recovery / cross-PR isolation (#693).
|
||||
|
||||
Reproduces the PR #688 → PR #692 formal-review sequence:
|
||||
|
||||
* durable lock holds REQUEST_CHANGES on open PR #688 @ head A (review_id 425)
|
||||
* reviewer leases PR #692 @ head B (6d86db…)
|
||||
* mark_final for #692 must succeed without correction unlock
|
||||
* cleanup of open #688 terminal remains forbidden (#594)
|
||||
* authorize_review_correction for #688 cannot unlock #692
|
||||
* eventual #692 APPROVE is unique and head-bound (review_id 426)
|
||||
* mark_final is idempotent for same PR/action/head
|
||||
* diagnosis returns one exact next_action + durable handoff payload
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import mcp_server
|
||||
import review_workflow_load
|
||||
import stale_review_decision_lock as srdl
|
||||
|
||||
# PR #688 / #692 incident fixtures (sanitized reconstruction from issue #693)
|
||||
HEAD_688 = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0"
|
||||
HEAD_692 = "6d86db793bbdfaed16bd54d93171f1dd66f234ca"
|
||||
PR_A = 688
|
||||
PR_B = 692
|
||||
REVIEW_ID_A = 425
|
||||
REVIEW_ID_B = 426
|
||||
|
||||
RC_688 = {
|
||||
"pr_number": PR_A,
|
||||
"action": "request_changes",
|
||||
"review_id": REVIEW_ID_A,
|
||||
"review_state": "request_changes",
|
||||
"head_sha": HEAD_688,
|
||||
}
|
||||
APPROVE_692 = {
|
||||
"pr_number": PR_B,
|
||||
"action": "approve",
|
||||
"review_id": REVIEW_ID_B,
|
||||
"review_state": "approve",
|
||||
"head_sha": HEAD_692,
|
||||
}
|
||||
|
||||
|
||||
def _lock(mutations=None, **kwargs):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
base = {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"session_pid": os.getpid(),
|
||||
"session_profile": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"final_review_decision_ready": False,
|
||||
"ready_pr_number": None,
|
||||
"ready_action": None,
|
||||
"ready_expected_head_sha": None,
|
||||
"ready_remote": None,
|
||||
"ready_org": None,
|
||||
"ready_repo": None,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": False,
|
||||
"correction_reason": None,
|
||||
"correction_pr_number": None,
|
||||
"correction_head_sha": None,
|
||||
"updated_at": now,
|
||||
"recorded_at": now,
|
||||
"writer_pid": os.getpid(),
|
||||
}
|
||||
base.update(kwargs)
|
||||
# Ensure TTL fields stay fresh unless the caller is testing expiry.
|
||||
if "recorded_at" not in kwargs:
|
||||
base["recorded_at"] = now
|
||||
if "updated_at" not in kwargs:
|
||||
base["updated_at"] = now
|
||||
return base
|
||||
|
||||
|
||||
REVIEWER_ENV = {
|
||||
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": (
|
||||
"gitea.read,gitea.pr.review,gitea.pr.approve,"
|
||||
"gitea.pr.request_changes,gitea.pr.comment"
|
||||
),
|
||||
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
|
||||
}
|
||||
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
],
|
||||
"forbidden_operations": ["gitea.pr.merge"],
|
||||
}
|
||||
|
||||
|
||||
def _seed(mutations=None, **kwargs):
|
||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||
mcp_server._save_review_decision_lock(_lock(mutations, **kwargs))
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
|
||||
def _no_lease():
|
||||
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
|
||||
|
||||
def _open_pr(number, head):
|
||||
return {
|
||||
"number": number,
|
||||
"state": "open",
|
||||
"merged": False,
|
||||
"merged_at": None,
|
||||
"head": {"sha": head},
|
||||
}
|
||||
|
||||
|
||||
class TestCrossPrIsolationPure(unittest.TestCase):
|
||||
def test_foreign_terminal_does_not_block_mark_boundary(self):
|
||||
lock = _lock([RC_688])
|
||||
self.assertFalse(
|
||||
srdl.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
srdl.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=PR_A, expected_head_sha=HEAD_688
|
||||
)
|
||||
)
|
||||
|
||||
def test_terminal_boundary_allows_fresh_decision_on_other_pr(self):
|
||||
lock = _lock([RC_688])
|
||||
self.assertTrue(
|
||||
srdl.terminal_boundary_allows_fresh_decision(
|
||||
lock,
|
||||
pr_number=PR_B,
|
||||
expected_head_sha=HEAD_692,
|
||||
operation="mark_ready",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
srdl.terminal_boundary_allows_fresh_decision(
|
||||
lock,
|
||||
pr_number=PR_A,
|
||||
expected_head_sha=HEAD_688,
|
||||
operation="mark_ready",
|
||||
)
|
||||
)
|
||||
|
||||
def test_unscoped_correction_does_not_apply(self):
|
||||
lock = _lock([RC_688], correction_authorized=True, correction_reason="x")
|
||||
# Missing correction_pr_number → fail closed (#693)
|
||||
self.assertFalse(
|
||||
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||
)
|
||||
|
||||
def test_scoped_correction_only_for_named_pr(self):
|
||||
lock = _lock(
|
||||
[RC_688],
|
||||
correction_authorized=True,
|
||||
correction_reason="mistake",
|
||||
correction_pr_number=PR_A,
|
||||
correction_head_sha=HEAD_688,
|
||||
)
|
||||
self.assertTrue(
|
||||
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||
)
|
||||
self.assertFalse(
|
||||
srdl.correction_applies(lock, pr_number=PR_B, expected_head_sha=HEAD_692)
|
||||
)
|
||||
|
||||
|
||||
class TestClassification(unittest.TestCase):
|
||||
def test_foreign_pr_terminal_classification(self):
|
||||
lock = _lock([RC_688])
|
||||
c = srdl.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=PR_B,
|
||||
target_head_sha=HEAD_692,
|
||||
pr_live=_open_pr(PR_A, HEAD_688),
|
||||
active_profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertEqual(c["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||
self.assertTrue(c["mark_final_allowed"])
|
||||
self.assertFalse(c["correction_eligible"])
|
||||
self.assertIn("do not call gitea_authorize_review_correction", c["next_action"])
|
||||
|
||||
def test_same_head_terminal_classification(self):
|
||||
lock = _lock([RC_688])
|
||||
c = srdl.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=PR_A,
|
||||
target_head_sha=HEAD_688,
|
||||
pr_live=_open_pr(PR_A, HEAD_688),
|
||||
)
|
||||
self.assertEqual(c["classification"], srdl.CLASS_TERMINAL_ACTIVE_SAME_HEAD)
|
||||
self.assertFalse(c["mark_final_allowed"])
|
||||
self.assertTrue(c["correction_eligible"])
|
||||
|
||||
def test_precise_failure_includes_durable_handoff(self):
|
||||
lock = _lock([RC_688])
|
||||
c = srdl.classify_review_decision_lock(
|
||||
lock, target_pr_number=PR_A, target_head_sha=HEAD_688
|
||||
)
|
||||
fail = srdl.build_precise_mark_final_failure(c)
|
||||
self.assertIn("exact_next_action", fail)
|
||||
self.assertIn("durable_issue_handoff", fail)
|
||||
self.assertTrue(fail["durable_issue_handoff"]["required"])
|
||||
|
||||
|
||||
class TestPr692Sequence(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False)
|
||||
self._env.start()
|
||||
self._prof = patch.object(
|
||||
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||
)
|
||||
self._prof.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._prof.stop()
|
||||
self._env.stop()
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_mark_final_692_succeeds_with_open_688_terminal(self):
|
||||
_seed([RC_688], writer_pid=25883, session_pid=60615)
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||
):
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=PR_B,
|
||||
action="approve",
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
self.assertEqual(lock["ready_pr_number"], PR_B)
|
||||
self.assertEqual(
|
||||
srdl.normalize_head_sha(lock["ready_expected_head_sha"]), HEAD_692
|
||||
)
|
||||
# Foreign terminal history preserved (non-destructive isolation).
|
||||
self.assertEqual(lock["live_mutations"][0]["pr_number"], PR_A)
|
||||
|
||||
def test_mark_final_idempotent_same_binding(self):
|
||||
_seed(
|
||||
[RC_688],
|
||||
final_review_decision_ready=True,
|
||||
ready_pr_number=PR_B,
|
||||
ready_action="approve",
|
||||
ready_expected_head_sha=HEAD_692,
|
||||
ready_remote="prgs",
|
||||
ready_org="Scaled-Tech-Consulting",
|
||||
ready_repo="Gitea-Tools",
|
||||
)
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||
):
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=PR_B,
|
||||
action="approve",
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(result["marked_ready"])
|
||||
self.assertTrue(result.get("idempotent"))
|
||||
|
||||
def test_cleanup_still_forbidden_while_688_open(self):
|
||||
_seed([RC_688])
|
||||
assessment = srdl.assess_stale_review_decision_lock(
|
||||
mcp_server._load_review_decision_lock(),
|
||||
pr_live=_open_pr(PR_A, HEAD_688),
|
||||
active_profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertFalse(assessment["cleanup_allowed"])
|
||||
self.assertFalse(assessment["is_moot"])
|
||||
|
||||
def test_correction_cannot_unlock_different_pr(self):
|
||||
_seed([RC_688])
|
||||
r = mcp_server.gitea_authorize_review_correction(
|
||||
prior_review_id=REVIEW_ID_A,
|
||||
prior_review_state="request_changes",
|
||||
reason="try unlock 692",
|
||||
operator_authorized=True,
|
||||
target_pr_number=PR_B,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
post_audit_comment=False,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("different PR" in x for x in r["reasons"]))
|
||||
|
||||
def test_scoped_correction_for_same_pr_posts_audit(self):
|
||||
_seed([RC_688])
|
||||
with patch(
|
||||
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch(
|
||||
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch("mcp_server.api_request", return_value={"id": 99901}):
|
||||
r = mcp_server.gitea_authorize_review_correction(
|
||||
prior_review_id=REVIEW_ID_A,
|
||||
prior_review_state="request_changes",
|
||||
reason="mistaken RC wording",
|
||||
operator_authorized=True,
|
||||
target_pr_number=PR_A,
|
||||
expected_head_sha=HEAD_688,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
post_audit_comment=True,
|
||||
)
|
||||
self.assertTrue(r["authorized"], r.get("reasons"))
|
||||
self.assertEqual(r.get("correction_pr_number"), PR_A)
|
||||
self.assertEqual(
|
||||
r.get("audit_comment_id"),
|
||||
99901,
|
||||
r.get("audit_comment_skipped") or r.get("audit_comment_error") or r,
|
||||
)
|
||||
|
||||
def test_diagnose_foreign_terminal_next_action(self):
|
||||
_seed([RC_688])
|
||||
with patch(
|
||||
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||
), patch(
|
||||
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch(
|
||||
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||
), patch("mcp_server.api_request", return_value=_open_pr(PR_A, HEAD_688)):
|
||||
d = mcp_server.gitea_diagnose_review_decision_lock(
|
||||
target_pr_number=PR_B,
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(d["success"], d.get("reasons"))
|
||||
self.assertEqual(d["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||
self.assertTrue(d["mark_final_allowed"])
|
||||
self.assertIn("mark_final", d["exact_next_action"])
|
||||
self.assertTrue(d["correction_as_generic_unlock_forbidden"])
|
||||
|
||||
def test_approval_ledger_unique_and_bound_after_sequence(self):
|
||||
"""After mark_final + record mutation, ledger binds unique approve to 692 head."""
|
||||
_seed([RC_688])
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||
):
|
||||
marked = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=PR_B,
|
||||
action="approve",
|
||||
expected_head_sha=HEAD_692,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(marked["marked_ready"])
|
||||
mcp_server.record_live_review_mutation(PR_B, "approve", REVIEW_ID_B)
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
terminals = [
|
||||
m
|
||||
for m in lock["live_mutations"]
|
||||
if m.get("action") in srdl.TERMINAL_REVIEW_ACTIONS
|
||||
and m.get("pr_number") == PR_B
|
||||
]
|
||||
self.assertEqual(len(terminals), 1)
|
||||
self.assertEqual(terminals[0]["review_id"], REVIEW_ID_B)
|
||||
self.assertEqual(
|
||||
srdl.normalize_head_sha(terminals[0].get("head_sha")), HEAD_692
|
||||
)
|
||||
# Same-head duplicate still blocked.
|
||||
self.assertTrue(
|
||||
srdl.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestSessionRestartWriterPid(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_writer_pid_change_does_not_inherit_foreign_block(self):
|
||||
# session_pid 60615 origin, writer later 25883 — still foreign isolation.
|
||||
_seed([RC_688], session_pid=60615, writer_pid=25883)
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(
|
||||
PR_B, "mark_ready", expected_head_sha=HEAD_692
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,693 @@
|
||||
"""Regression tests for the PR #703 REQUEST_CHANGES findings F1–F6 (#702).
|
||||
|
||||
Formal review at head 889931d independently reproduced six defects:
|
||||
|
||||
F1 — stale-binding recovery ran only AFTER the terminal launcher probe in
|
||||
``gitea_resolve_task_capability``; a worktree removed mid-session wedged
|
||||
every mutation-task resolution on 'missing cwd' before recovery.
|
||||
F2 — ``_resolve_preflight_workspace_path`` skipped the existence checks the
|
||||
canonical mutation context applies; a dead binding could produce empty
|
||||
porcelain and read as a clean workspace.
|
||||
F3 — ``orphaned_expired_superseded_head`` cleanup accepted unknown
|
||||
``worktree_exists``/``worktree_clean`` evidence.
|
||||
F4 — the 4-hour session-state TTL silently discarded recovery-critical
|
||||
crash-orphan shadow evidence.
|
||||
F5 — the single same-profile shadow slot let concurrent sessions overwrite
|
||||
each other's crash evidence.
|
||||
F6 — the environment was cleared BEFORE the audit record was persisted, and
|
||||
audit-write failure was swallowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_session_state as mss # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import reviewer_pr_lease as leases # noqa: E402
|
||||
import stale_binding_recovery as sbr # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
|
||||
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
OLD_HEAD = "b" * 40
|
||||
NEW_HEAD = "6" * 40
|
||||
|
||||
CONFIG = {
|
||||
"version": 2,
|
||||
"contexts": {
|
||||
"ctx": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"enabled": True,
|
||||
"context": "ctx",
|
||||
"role": "author",
|
||||
"username": "jcwalker3",
|
||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||
"allowed_operations": [
|
||||
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
|
||||
"gitea.pr.create", "gitea.branch.push",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||
],
|
||||
"execution_profile": "prgs-author",
|
||||
},
|
||||
},
|
||||
"rules": {"allow_runtime_switching": False},
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class _CapabilityHarness(unittest.TestCase):
|
||||
"""Shared config/env plumbing for resolve-capability integration tests."""
|
||||
|
||||
def setUp(self):
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {
|
||||
"host": "gitea.example.com",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
def tearDown(self):
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _env(self, **extra):
|
||||
env = {
|
||||
"GITEA_MCP_CONFIG": self.config_path,
|
||||
"GITEA_MCP_PROFILE": "prgs-author",
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
def _deleted_worktree(self) -> str:
|
||||
path = tempfile.mkdtemp(prefix="gitea-removed-worktree-")
|
||||
shutil.rmtree(path)
|
||||
return path
|
||||
|
||||
|
||||
class TestF1RecoveryBeforeTerminalProbe(_CapabilityHarness):
|
||||
"""F1: recovery must run before the terminal cwd probe can wedge."""
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_removed_worktree_recovers_before_probe(self, _auth, _api):
|
||||
missing = self._deleted_worktree()
|
||||
env = self._env(
|
||||
GITEA_ACTIVE_WORKTREE=missing,
|
||||
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||
)
|
||||
with patch.dict(os.environ, env):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_pr", remote="prgs"
|
||||
)
|
||||
# The provably-stale binding was cleared before the probe ran.
|
||||
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||
self.assertFalse(res.get("terminal_launcher_unhealthy"))
|
||||
report = res.get("stale_binding_recovery") or {}
|
||||
self.assertEqual(
|
||||
report.get("classification"),
|
||||
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
)
|
||||
self.assertTrue(report.get("recovery_performed"))
|
||||
self.assertTrue(res.get("allowed_in_current_session"))
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_probe_block_still_carries_recovery_report(self, _auth, _api):
|
||||
# Recovery disabled (test mode without the force flag) preserves the
|
||||
# fail-closed probe block, but the block must now carry the
|
||||
# classification evidence instead of hiding it.
|
||||
missing = self._deleted_worktree()
|
||||
env = self._env(
|
||||
GITEA_ACTIVE_WORKTREE=missing,
|
||||
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||
)
|
||||
with patch.dict(os.environ, env):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_pr", remote="prgs"
|
||||
)
|
||||
self.assertIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||
self.assertTrue(res.get("terminal_launcher_unhealthy"))
|
||||
report = res.get("stale_binding_recovery") or {}
|
||||
self.assertEqual(
|
||||
report.get("classification"),
|
||||
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
)
|
||||
self.assertFalse(report.get("recovery_performed"))
|
||||
|
||||
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||
def test_unverified_binding_is_never_cleared_by_reordering(self, _auth, _api):
|
||||
# F1 must not weaken authorization: an existing, uncorroborated
|
||||
# binding survives resolution untouched even with recovery forced.
|
||||
existing = tempfile.mkdtemp(prefix="gitea-existing-worktree-")
|
||||
self.addCleanup(shutil.rmtree, existing, ignore_errors=True)
|
||||
env = self._env(
|
||||
GITEA_ACTIVE_WORKTREE=existing,
|
||||
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||
)
|
||||
with patch.dict(os.environ, env):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_pr", remote="prgs"
|
||||
)
|
||||
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), existing)
|
||||
report = res.get("stale_binding_recovery") or {}
|
||||
self.assertFalse(report.get("recovery_performed"))
|
||||
|
||||
|
||||
class TestF2PreflightPathVerification(_CapabilityHarness):
|
||||
"""F2: preflight and mutation contexts verify resolved paths alike."""
|
||||
|
||||
def test_preflight_and_mutation_context_agree_on_dead_binding(self):
|
||||
missing = self._deleted_worktree()
|
||||
env = self._env(GITEA_ACTIVE_WORKTREE=missing)
|
||||
with patch.dict(os.environ, env):
|
||||
preflight = mcp_server._resolve_preflight_workspace_path(None)
|
||||
mutation = mcp_server._resolve_namespace_mutation_context(None)
|
||||
self.assertEqual(preflight, mutation["workspace_path"])
|
||||
self.assertNotEqual(preflight, os.path.realpath(missing))
|
||||
|
||||
def test_missing_explicit_worktree_never_reads_clean(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._env()):
|
||||
porcelain = mcp_server._get_workspace_porcelain(worktree_path=missing)
|
||||
self.assertEqual(
|
||||
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
)
|
||||
# The sentinel parses as a tracked dirty entry — never clean.
|
||||
self.assertTrue(mcp_server._parse_porcelain_entries(porcelain))
|
||||
|
||||
def test_workspace_deleted_during_evaluation_never_reads_clean(self):
|
||||
# TOCTOU: resolution succeeded, then the path vanished before git ran.
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._env()):
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"_resolve_preflight_workspace_path",
|
||||
return_value=missing,
|
||||
):
|
||||
porcelain = mcp_server._get_workspace_porcelain()
|
||||
self.assertEqual(
|
||||
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
)
|
||||
|
||||
def test_git_failure_never_reads_clean(self):
|
||||
class _Failed:
|
||||
returncode = 128
|
||||
stdout = ""
|
||||
stderr = "fatal: not a git repository"
|
||||
|
||||
with patch.dict(os.environ, self._env()):
|
||||
with patch.object(
|
||||
mcp_server.subprocess, "run", return_value=_Failed()
|
||||
):
|
||||
porcelain = mcp_server._get_workspace_porcelain()
|
||||
self.assertEqual(
|
||||
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||
)
|
||||
|
||||
def test_symlinked_binding_to_deleted_target_is_demoted(self):
|
||||
target = tempfile.mkdtemp(prefix="gitea-symlink-target-")
|
||||
holder = tempfile.mkdtemp(prefix="gitea-symlink-holder-")
|
||||
self.addCleanup(shutil.rmtree, holder, ignore_errors=True)
|
||||
link = os.path.join(holder, "worktree-link")
|
||||
os.symlink(target, link)
|
||||
shutil.rmtree(target)
|
||||
demotions: list[str] = []
|
||||
_path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=os.getcwd(),
|
||||
env={"GITEA_ACTIVE_WORKTREE": link},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "MCP server process root (default)")
|
||||
self.assertEqual(len(demotions), 1)
|
||||
|
||||
def test_dead_binding_falls_through_to_live_role_binding(self):
|
||||
# Path changes during evaluation: the dead candidate demotes at
|
||||
# verification time and never resurrects over the live fallback.
|
||||
alive = tempfile.mkdtemp(prefix="gitea-alive-worktree-")
|
||||
self.addCleanup(shutil.rmtree, alive, ignore_errors=True)
|
||||
missing = self._deleted_worktree()
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=os.getcwd(),
|
||||
env={
|
||||
"GITEA_ACTIVE_WORKTREE": missing,
|
||||
"GITEA_REVIEWER_WORKTREE": alive,
|
||||
},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(path, os.path.realpath(alive))
|
||||
self.assertEqual(
|
||||
source, "GITEA_REVIEWER_WORKTREE environment variable"
|
||||
)
|
||||
|
||||
|
||||
class TestF3AffirmativeWorktreeEvidence(unittest.TestCase):
|
||||
"""F3: unknown worktree evidence must fail closed for crash orphans."""
|
||||
|
||||
def _comments(self) -> list[dict]:
|
||||
stale = _now() - timedelta(hours=3)
|
||||
body = leases.format_lease_body(
|
||||
repo=REPO,
|
||||
pr_number=701,
|
||||
issue_number=699,
|
||||
reviewer_identity="sysadmin",
|
||||
profile="prgs-reviewer",
|
||||
session_id="65664-4f248d213d60",
|
||||
worktree="branches/review-pr-654",
|
||||
phase="validation-start",
|
||||
candidate_head=OLD_HEAD,
|
||||
target_branch="master",
|
||||
target_branch_sha="2" * 40,
|
||||
last_activity=stale,
|
||||
expires_at=stale + timedelta(minutes=120),
|
||||
)
|
||||
return [{
|
||||
"id": 11131,
|
||||
"body": body,
|
||||
"user": {"login": "sysadmin"},
|
||||
"author": "sysadmin",
|
||||
"created_at": stale.isoformat(),
|
||||
"updated_at": stale.isoformat(),
|
||||
}]
|
||||
|
||||
def _assess(self, **kwargs):
|
||||
defaults = dict(
|
||||
pr_number=701,
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
repo=REPO,
|
||||
expected_repo=REPO,
|
||||
requesting_session_id="fresh-controller",
|
||||
controller_recovery_authorized=True,
|
||||
owner_process_alive=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
self._comments(), **defaults
|
||||
)
|
||||
|
||||
def test_unknown_worktree_evidence_fails_closed(self):
|
||||
result = self._assess(worktree_exists=None, worktree_clean=None)
|
||||
self.assertEqual(
|
||||
result["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertIn(
|
||||
"affirmative safe evidence",
|
||||
" ".join(result["fail_closed_reasons"]),
|
||||
)
|
||||
|
||||
def test_unknown_cleanliness_with_existing_worktree_fails_closed(self):
|
||||
result = self._assess(worktree_exists=True, worktree_clean=None)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
def test_affirmative_clean_worktree_is_eligible(self):
|
||||
result = self._assess(worktree_exists=True, worktree_clean=True)
|
||||
self.assertEqual(
|
||||
result["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertTrue(result["cleanup_allowed"])
|
||||
|
||||
def test_affirmative_absent_worktree_is_eligible(self):
|
||||
result = self._assess(worktree_exists=False, worktree_clean=None)
|
||||
self.assertTrue(result["cleanup_allowed"])
|
||||
|
||||
def test_matrix_matches_diagnosis_gate(self):
|
||||
# The cleanup matrix and the diagnosis path must agree: unknown
|
||||
# evidence yields acquire-only, affirmative evidence yields cleanup.
|
||||
diagnosis_unknown = leases.diagnose_reviewer_pr_lease_handoff(
|
||||
self._comments(),
|
||||
pr_number=701,
|
||||
current_session_id=None,
|
||||
current_reviewer_identity="fresh-reviewer",
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
owner_process_alive=False,
|
||||
worktree_exists=None,
|
||||
worktree_clean=None,
|
||||
)
|
||||
assess_unknown = self._assess(worktree_exists=None, worktree_clean=None)
|
||||
self.assertNotEqual(
|
||||
diagnosis_unknown["next_action"],
|
||||
"cleanup_obsolete_reviewer_comment_lease",
|
||||
)
|
||||
self.assertFalse(assess_unknown["cleanup_allowed"])
|
||||
|
||||
|
||||
class TestF4CrashShadowDurability(unittest.TestCase):
|
||||
"""F4: crash-orphan evidence survives the former 4h TTL boundary."""
|
||||
|
||||
SID = "82984-abcabcabcabc"
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self._env = patch.dict(
|
||||
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
|
||||
)
|
||||
self._env.start()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def tearDown(self):
|
||||
self._env.stop()
|
||||
self._dir.cleanup()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def _age_record(self, kind: str, hours: float, instance_id: str | None = None):
|
||||
path = mss.state_file_path(kind=kind, instance_id=instance_id)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
envelope = json.load(fh)
|
||||
stamp = (
|
||||
(_now() - timedelta(hours=hours)).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
envelope["recorded_at"] = stamp
|
||||
envelope["updated_at"] = stamp
|
||||
envelope["payload"]["recorded_at"] = stamp
|
||||
envelope["payload"]["updated_at"] = stamp
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(envelope, fh)
|
||||
|
||||
def _save_shadow(self) -> None:
|
||||
mss.save_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||
instance_id=self.SID,
|
||||
payload={"session_id": self.SID, "owner_pid": os.getpid()},
|
||||
)
|
||||
|
||||
def _load_shadow(self):
|
||||
return mss.load_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
|
||||
)
|
||||
|
||||
def test_shadow_loads_before_former_ttl_boundary(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=3.9, instance_id=self.SID
|
||||
)
|
||||
self.assertIsNotNone(self._load_shadow())
|
||||
|
||||
def test_shadow_loads_at_former_ttl_boundary(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=4.0, instance_id=self.SID
|
||||
)
|
||||
self.assertIsNotNone(self._load_shadow())
|
||||
|
||||
def test_shadow_loads_long_after_former_ttl_boundary(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||
)
|
||||
record = self._load_shadow()
|
||||
self.assertIsNotNone(record)
|
||||
self.assertEqual(record.get("session_id"), self.SID)
|
||||
|
||||
def test_stale_binding_audit_record_is_also_durable(self):
|
||||
mss.save_state(
|
||||
kind=mss.KIND_STALE_BINDING_RECOVERY,
|
||||
payload={"cleared_env": "GITEA_ACTIVE_WORKTREE"},
|
||||
)
|
||||
self._age_record(mss.KIND_STALE_BINDING_RECOVERY, hours=27.0)
|
||||
self.assertIsNotNone(
|
||||
mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
|
||||
)
|
||||
|
||||
def test_non_recovery_kinds_keep_ttl(self):
|
||||
mss.save_state(
|
||||
kind=mss.KIND_WORKFLOW_LOAD, payload={"workflow_hash": "abc"}
|
||||
)
|
||||
self._age_record(mss.KIND_WORKFLOW_LOAD, hours=5.0)
|
||||
self.assertIsNone(mss.load_state(kind=mss.KIND_WORKFLOW_LOAD))
|
||||
|
||||
def test_sanctioned_clear_is_the_terminal_reconciliation(self):
|
||||
self._save_shadow()
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||
)
|
||||
mss.clear_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
|
||||
)
|
||||
self.assertIsNone(self._load_shadow())
|
||||
self.assertEqual(
|
||||
mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE), []
|
||||
)
|
||||
|
||||
def test_crash_orphan_recovery_works_after_former_ttl(self):
|
||||
# End to end: a crashed session's shadow still proves owner exit a
|
||||
# day later.
|
||||
leases.record_session_lease(
|
||||
{
|
||||
"pr_number": 701,
|
||||
"session_id": self.SID,
|
||||
"worktree": "branches/review-pr-654",
|
||||
"candidate_head": OLD_HEAD,
|
||||
"repo": REPO,
|
||||
"comment_id": 11131,
|
||||
"profile": "prgs-reviewer",
|
||||
"reviewer_identity": "sysadmin",
|
||||
"phase": "claimed",
|
||||
},
|
||||
lease_provenance={"source": "acquire", "comment_id": 11131},
|
||||
)
|
||||
leases._SESSION_LEASE = None # simulated crash: no sanctioned clear
|
||||
self._age_record(
|
||||
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||
)
|
||||
shadow = leases.load_session_lease_shadow(session_id=self.SID)
|
||||
self.assertIsNotNone(shadow)
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow,
|
||||
lease={"session_id": self.SID},
|
||||
current_pid=os.getpid() + 1,
|
||||
pid_alive=False,
|
||||
)
|
||||
self.assertTrue(verdict["orphan_evidence"])
|
||||
self.assertIs(verdict["owner_process_alive"], False)
|
||||
|
||||
|
||||
class TestF5ConcurrentSessionShadows(unittest.TestCase):
|
||||
"""F5: concurrent same-profile sessions keep distinct shadow records."""
|
||||
|
||||
SID_A = "11111-aaaaaaaaaaaa"
|
||||
SID_B = "22222-bbbbbbbbbbbb"
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self._env = patch.dict(
|
||||
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
|
||||
)
|
||||
self._env.start()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def tearDown(self):
|
||||
self._env.stop()
|
||||
self._dir.cleanup()
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def _lease(self, session_id: str, pr_number: int, head: str) -> dict:
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"session_id": session_id,
|
||||
"worktree": f"branches/review-pr-{pr_number}-{session_id[:5]}",
|
||||
"candidate_head": head,
|
||||
"repo": REPO,
|
||||
"comment_id": 11221,
|
||||
"profile": "prgs-reviewer",
|
||||
"reviewer_identity": "sysadmin",
|
||||
"phase": "claimed",
|
||||
}
|
||||
|
||||
def _record(self, lease: dict) -> None:
|
||||
leases.record_session_lease(
|
||||
lease,
|
||||
lease_provenance={"source": "acquire", "comment_id": 11221},
|
||||
)
|
||||
|
||||
def test_interleaved_sessions_do_not_overwrite_each_other(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||
# Session A heartbeats again after B wrote.
|
||||
updated_a = self._lease(self.SID_A, 703, OLD_HEAD)
|
||||
updated_a["phase"] = "validation-start"
|
||||
self._record(updated_a)
|
||||
|
||||
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||
shadow_b = leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
|
||||
self.assertEqual(shadow_a.get("pr_number"), 703)
|
||||
self.assertEqual(shadow_a.get("phase"), "validation-start")
|
||||
self.assertEqual(shadow_b.get("session_id"), self.SID_B)
|
||||
self.assertEqual(shadow_b.get("pr_number"), 701)
|
||||
self.assertEqual(shadow_b.get("candidate_head"), NEW_HEAD)
|
||||
|
||||
def test_shadow_lookup_never_misattributes(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self.assertIsNone(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
)
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_A),
|
||||
lease={"session_id": self.SID_B},
|
||||
pid_alive=False,
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
|
||||
def test_clearing_one_session_preserves_the_other(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||
# Sanctioned clear of B (the currently recorded in-memory lease).
|
||||
leases.clear_session_lease()
|
||||
self.assertIsNone(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
)
|
||||
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||
self.assertIsNotNone(shadow_a)
|
||||
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
|
||||
|
||||
def test_reconnected_process_recovers_by_session_id(self):
|
||||
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||
# Simulated crash + reconnect: in-memory state is gone, durable
|
||||
# records remain enumerable and individually attributable.
|
||||
leases._SESSION_LEASE = None
|
||||
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||
self.assertEqual(
|
||||
sorted(r.get("session_id") for r in records),
|
||||
sorted([self.SID_A, self.SID_B]),
|
||||
)
|
||||
shadow = leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
self.assertEqual(shadow.get("pr_number"), 701)
|
||||
|
||||
def test_legacy_single_slot_record_still_resolves_by_session_id(self):
|
||||
# Upgrade path: a record written by pre-F5 code (no instance key)
|
||||
# remains usable when its payload names the requested session.
|
||||
mss.save_state(
|
||||
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||
payload={"session_id": self.SID_A, "owner_pid": os.getpid()},
|
||||
)
|
||||
shadow = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||
self.assertIsNotNone(shadow)
|
||||
self.assertIsNone(
|
||||
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||
)
|
||||
|
||||
|
||||
class TestF6AuditBeforeClear(_CapabilityHarness):
|
||||
"""F6: the durable audit record is persisted before the env clears."""
|
||||
|
||||
def _recovery_env(self, missing: str) -> dict:
|
||||
return self._env(
|
||||
GITEA_ACTIVE_WORKTREE=missing,
|
||||
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||
)
|
||||
|
||||
def test_audit_write_failure_blocks_the_clear(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
with patch.object(
|
||||
mss, "save_state", side_effect=OSError("disk full")
|
||||
):
|
||||
report = mcp_server._assess_stale_active_binding(
|
||||
auto_recover=True
|
||||
)
|
||||
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), missing)
|
||||
self.assertFalse(report["recovery_performed"])
|
||||
self.assertIs(report.get("audit_persisted"), False)
|
||||
self.assertTrue(report.get("audit_write_failed"))
|
||||
self.assertIn("NOT cleared", " ".join(report.get("reasons") or []))
|
||||
|
||||
def test_retry_after_audit_failure_recovers(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
with patch.object(
|
||||
mss, "save_state", side_effect=OSError("disk full")
|
||||
):
|
||||
first = mcp_server._assess_stale_active_binding(
|
||||
auto_recover=True
|
||||
)
|
||||
self.assertFalse(first["recovery_performed"])
|
||||
# Persistence recovered; the retry clears with a durable audit.
|
||||
second = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||
self.assertTrue(second["recovery_performed"])
|
||||
self.assertIs(second.get("audit_persisted"), True)
|
||||
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||
audit = mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
|
||||
self.assertIsNotNone(audit)
|
||||
self.assertEqual(audit.get("recovery_status"), "performed")
|
||||
self.assertEqual(audit.get("cleared_value"), missing)
|
||||
|
||||
def test_audit_record_exists_before_env_clear(self):
|
||||
missing = self._deleted_worktree()
|
||||
observed: list[tuple[str | None, str | None]] = []
|
||||
real_save = mss.save_state
|
||||
|
||||
def _spy(**kwargs):
|
||||
observed.append(
|
||||
(
|
||||
(kwargs.get("payload") or {}).get("recovery_status"),
|
||||
os.environ.get("GITEA_ACTIVE_WORKTREE"),
|
||||
)
|
||||
)
|
||||
return real_save(**kwargs)
|
||||
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
with patch.object(mss, "save_state", side_effect=_spy):
|
||||
report = mcp_server._assess_stale_active_binding(
|
||||
auto_recover=True
|
||||
)
|
||||
self.assertTrue(report["recovery_performed"])
|
||||
self.assertGreaterEqual(len(observed), 2)
|
||||
pending_status, env_at_pending = observed[0]
|
||||
performed_status, env_at_performed = observed[-1]
|
||||
# Ordering proof: the binding was still present while the pending
|
||||
# audit persisted, and gone by the time the performed status wrote.
|
||||
self.assertEqual(pending_status, "pending")
|
||||
self.assertEqual(env_at_pending, missing)
|
||||
self.assertEqual(performed_status, "performed")
|
||||
self.assertIsNone(env_at_performed)
|
||||
|
||||
def test_recovery_is_idempotent_after_success(self):
|
||||
missing = self._deleted_worktree()
|
||||
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||
first = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||
second = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||
self.assertTrue(first["recovery_performed"])
|
||||
self.assertEqual(second["classification"], sbr.CLASSIFICATION_UNBOUND)
|
||||
self.assertFalse(second["recovery_performed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Regression tests for #702: stale runtime binding + orphaned durable lease.
|
||||
|
||||
Reproduces the PR #701 incident (lease 65664-4f248d213d60, comments
|
||||
11129/11131): the MCP daemon crashed without teardown, destroying the
|
||||
in-memory session lease while the durable comment lease survived, and the
|
||||
auto-reconnected daemon inherited a stale ``GITEA_ACTIVE_WORKTREE`` binding
|
||||
(``branches/review-pr-654``) from its parent environment.
|
||||
|
||||
Covers the five mandated scenarios:
|
||||
|
||||
1. lost in-session leases — durable shadow survives a crash and yields
|
||||
provable owner-process evidence
|
||||
2. old-head leases — expired superseded-head lease with no terminal
|
||||
review becomes recoverable only with orphan evidence + controller authority
|
||||
3. runtime/worktree mismatch — env bindings to deleted worktrees are demoted,
|
||||
never silently bound
|
||||
4. managed reconnect — boot-inherited uncorroborated bindings are
|
||||
surfaced for managed recovery; provably stale ones are clear-eligible
|
||||
5. safe expiry — pre-expiry stays fail-closed wait (no steal);
|
||||
canonical expiry unlocks acquisition and guarded cleanup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import reviewer_pr_lease as leases # noqa: E402
|
||||
import stale_binding_recovery as sbr # noqa: E402
|
||||
|
||||
# PR #701 incident fixtures.
|
||||
OLD_HEAD = "b4d0cb22e452a07c8e816745c704ddb0f43edf0b"
|
||||
NEW_HEAD = "6b675f5c834b41f9d74e8a54294ff44dddf28ae4"
|
||||
CRASHED_SESSION = "65664-4f248d213d60"
|
||||
LEASE_COMMENT = 11131
|
||||
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||
PR = 701
|
||||
ISSUE = 699
|
||||
LEASE_WORKTREE = "branches/review-fix-issue-699-structured-auth-mcp-errors"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _lease_comment(
|
||||
*,
|
||||
phase: str = "validation-start",
|
||||
candidate_head: str = OLD_HEAD,
|
||||
session_id: str = CRASHED_SESSION,
|
||||
comment_id: int = LEASE_COMMENT,
|
||||
last_activity: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
worktree: str = LEASE_WORKTREE,
|
||||
) -> dict:
|
||||
stamp = last_activity or _now()
|
||||
body = leases.format_lease_body(
|
||||
repo=REPO,
|
||||
pr_number=PR,
|
||||
issue_number=ISSUE,
|
||||
reviewer_identity="sysadmin",
|
||||
profile="prgs-reviewer",
|
||||
session_id=session_id,
|
||||
worktree=worktree,
|
||||
phase=phase,
|
||||
candidate_head=candidate_head,
|
||||
target_branch="master",
|
||||
target_branch_sha="2" * 40,
|
||||
last_activity=stamp,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return {
|
||||
"id": comment_id,
|
||||
"body": body,
|
||||
"user": {"login": "sysadmin"},
|
||||
"author": "sysadmin",
|
||||
"created_at": stamp.isoformat(),
|
||||
"updated_at": stamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _expired_superseded_comments() -> list[dict]:
|
||||
stale = _now() - timedelta(hours=3)
|
||||
return [_lease_comment(
|
||||
last_activity=stale,
|
||||
expires_at=stale + timedelta(minutes=120),
|
||||
)]
|
||||
|
||||
|
||||
def _fresh_superseded_comments() -> list[dict]:
|
||||
recent = _now() - timedelta(minutes=10)
|
||||
return [_lease_comment(
|
||||
last_activity=recent,
|
||||
expires_at=recent + timedelta(minutes=120),
|
||||
)]
|
||||
|
||||
|
||||
class TestLostInSessionLeaseShadow(unittest.TestCase):
|
||||
"""Scenario 1: the durable shadow survives a daemon crash (#702 AC3)."""
|
||||
|
||||
def setUp(self):
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def tearDown(self):
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def _record(self) -> dict:
|
||||
return leases.record_session_lease(
|
||||
{
|
||||
"pr_number": PR,
|
||||
"issue_number": ISSUE,
|
||||
"session_id": CRASHED_SESSION,
|
||||
"reviewer_identity": "sysadmin",
|
||||
"profile": "prgs-reviewer",
|
||||
"worktree": LEASE_WORKTREE,
|
||||
"phase": "claimed",
|
||||
"candidate_head": OLD_HEAD,
|
||||
"repo": REPO,
|
||||
"comment_id": LEASE_COMMENT,
|
||||
},
|
||||
lease_provenance={"source": "acquire", "comment_id": LEASE_COMMENT},
|
||||
)
|
||||
|
||||
def test_shadow_written_on_record_and_survives_simulated_crash(self):
|
||||
self._record()
|
||||
# Simulated unhandled crash: the in-memory dict dies with the process
|
||||
# but the sanctioned clear path never runs.
|
||||
leases._SESSION_LEASE = None
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
self.assertIsNotNone(shadow)
|
||||
self.assertEqual(shadow.get("session_id"), CRASHED_SESSION)
|
||||
self.assertEqual(shadow.get("pr_number"), PR)
|
||||
self.assertEqual(int(shadow.get("owner_pid")), os.getpid())
|
||||
|
||||
def test_sanctioned_clear_removes_shadow(self):
|
||||
self._record()
|
||||
leases.clear_session_lease()
|
||||
self.assertIsNone(leases.load_session_lease_shadow())
|
||||
|
||||
def test_orphan_assessment_dead_owner_pid_proves_exit(self):
|
||||
self._record()
|
||||
leases._SESSION_LEASE = None
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
lease = {"session_id": CRASHED_SESSION}
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow, lease=lease, current_pid=os.getpid() + 1, pid_alive=False
|
||||
)
|
||||
self.assertTrue(verdict["orphan_evidence"])
|
||||
self.assertIs(verdict["owner_process_alive"], False)
|
||||
|
||||
def test_orphan_assessment_alive_pid_is_not_ownership_proof(self):
|
||||
self._record()
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow,
|
||||
lease={"session_id": CRASHED_SESSION},
|
||||
current_pid=os.getpid() + 1,
|
||||
pid_alive=True,
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
self.assertIsNone(verdict["owner_process_alive"])
|
||||
|
||||
def test_orphan_assessment_session_mismatch_proves_nothing(self):
|
||||
self._record()
|
||||
shadow = leases.load_session_lease_shadow()
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
shadow, lease={"session_id": "other-999"}, pid_alive=False
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
self.assertIsNone(verdict["owner_process_alive"])
|
||||
|
||||
def test_orphan_assessment_without_shadow_is_unknown(self):
|
||||
verdict = leases.assess_crashed_session_lease_orphan(
|
||||
None, lease={"session_id": CRASHED_SESSION}
|
||||
)
|
||||
self.assertFalse(verdict["orphan_evidence"])
|
||||
self.assertIsNone(verdict["owner_process_alive"])
|
||||
|
||||
|
||||
class TestOldHeadLeaseCleanup(unittest.TestCase):
|
||||
"""Scenario 2: expired superseded-head lease with no terminal review."""
|
||||
|
||||
def _assess(self, comments, **kwargs):
|
||||
defaults = dict(
|
||||
pr_number=PR,
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
repo=REPO,
|
||||
expected_repo=REPO,
|
||||
requesting_session_id="fresh-controller",
|
||||
controller_recovery_authorized=True,
|
||||
worktree_exists=True,
|
||||
worktree_clean=True,
|
||||
owner_process_alive=False,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
comments, **defaults
|
||||
)
|
||||
|
||||
def test_expired_orphan_with_full_evidence_is_cleanup_eligible(self):
|
||||
result = self._assess(_expired_superseded_comments())
|
||||
self.assertEqual(result["classification"], "orphaned_expired_superseded_head")
|
||||
self.assertTrue(result["cleanup_allowed"])
|
||||
self.assertIn("phase: released", result["release_body"] or "")
|
||||
self.assertEqual(
|
||||
result["exact_next_action"], "cleanup_obsolete_reviewer_comment_lease"
|
||||
)
|
||||
|
||||
def test_expired_orphan_without_owner_evidence_fails_closed(self):
|
||||
result = self._assess(
|
||||
_expired_superseded_comments(), owner_process_alive=None
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertIn(
|
||||
"provable owner-process-exit evidence",
|
||||
" ".join(result["fail_closed_reasons"]),
|
||||
)
|
||||
|
||||
def test_expired_orphan_without_controller_authority_fails_closed(self):
|
||||
result = self._assess(
|
||||
_expired_superseded_comments(), controller_recovery_authorized=False
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
def test_expired_orphan_with_dirty_worktree_fails_closed(self):
|
||||
result = self._assess(
|
||||
_expired_superseded_comments(),
|
||||
worktree_clean=False,
|
||||
worktree_has_unpreserved_work=True,
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
def test_unexpired_superseded_no_terminal_stays_ambiguous_wait(self):
|
||||
# Regression guard: pre-expiry there is still no steal path.
|
||||
result = self._assess(_fresh_superseded_comments())
|
||||
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
self.assertEqual(result["exact_next_action"], "wait")
|
||||
|
||||
|
||||
class TestRuntimeWorktreeMismatch(unittest.TestCase):
|
||||
"""Scenario 3: env bindings to deleted worktrees are demoted (#702 AC2)."""
|
||||
|
||||
def test_missing_active_env_binding_is_demoted(self):
|
||||
demotions: list[str] = []
|
||||
missing = "/nonexistent/branches/review-pr-654"
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root=os.getcwd(),
|
||||
env={"GITEA_ACTIVE_WORKTREE": missing},
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "MCP server process root (default)")
|
||||
self.assertEqual(len(demotions), 1)
|
||||
self.assertIn("no longer exists", demotions[0])
|
||||
|
||||
def test_missing_active_falls_through_to_existing_role_env(self):
|
||||
existing = os.getcwd()
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root="/tmp",
|
||||
env={
|
||||
"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654",
|
||||
"GITEA_REVIEWER_WORKTREE": existing,
|
||||
},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(path, os.path.realpath(existing))
|
||||
self.assertEqual(source, "GITEA_REVIEWER_WORKTREE environment variable")
|
||||
|
||||
def test_existing_active_env_binding_still_wins(self):
|
||||
existing = os.getcwd()
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
process_project_root="/tmp",
|
||||
env={"GITEA_ACTIVE_WORKTREE": existing},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(path, os.path.realpath(existing))
|
||||
self.assertEqual(source, "GITEA_ACTIVE_WORKTREE environment variable")
|
||||
|
||||
def test_explicit_worktree_path_argument_is_never_demoted(self):
|
||||
missing = "/nonexistent/branches/explicit"
|
||||
path, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
worktree_path=missing,
|
||||
process_project_root="/tmp",
|
||||
env={},
|
||||
verify_paths=True,
|
||||
)
|
||||
self.assertEqual(source, "worktree_path argument")
|
||||
|
||||
def test_mutation_context_reports_demotion_in_ignored_bindings(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="reviewer",
|
||||
worktree_path=None,
|
||||
process_project_root=os.getcwd(),
|
||||
env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"},
|
||||
)
|
||||
joined = " ".join(ctx["ignored_bindings"])
|
||||
self.assertIn("stale binding, #702", joined)
|
||||
|
||||
|
||||
class TestManagedReconnectRecovery(unittest.TestCase):
|
||||
"""Scenario 4: boot-inherited bindings and the sanctioned recovery plan."""
|
||||
|
||||
def test_uncorroborated_inherited_reviewer_binding_is_unverified(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value=os.getcwd(),
|
||||
role_env_value=None,
|
||||
session_lease_worktree=None,
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||
)
|
||||
self.assertFalse(classification["clear_eligible"])
|
||||
plan = sbr.plan_recovery(classification)
|
||||
self.assertFalse(plan["clear_allowed"])
|
||||
self.assertIn("managed", plan["exact_next_action"])
|
||||
self.assertIn("do not clear or rewrite", plan["exact_next_action"])
|
||||
|
||||
def test_missing_path_binding_is_provably_stale_and_clear_eligible(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value="/nonexistent/branches/review-pr-654",
|
||||
boot_inherited=True,
|
||||
path_exists=False,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"],
|
||||
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||
)
|
||||
plan = sbr.plan_recovery(classification)
|
||||
self.assertTrue(plan["clear_allowed"])
|
||||
|
||||
def test_inherited_binding_superseded_by_session_lease_is_clear_eligible(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value="/tmp",
|
||||
session_lease_worktree=os.getcwd(),
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"],
|
||||
sbr.CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||
)
|
||||
self.assertTrue(sbr.plan_recovery(classification)["clear_allowed"])
|
||||
|
||||
def test_post_boot_binding_conflict_is_surfaced_not_cleared(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value="/tmp",
|
||||
session_lease_worktree=os.getcwd(),
|
||||
boot_inherited=False,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||
)
|
||||
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
|
||||
|
||||
def test_corroborated_bindings_are_untouched(self):
|
||||
for kwargs in (
|
||||
dict(role_env_value=os.getcwd()),
|
||||
dict(session_lease_worktree=os.getcwd()),
|
||||
):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value=os.getcwd(),
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
**kwargs,
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
|
||||
)
|
||||
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
|
||||
|
||||
def test_author_inherited_binding_stays_corroborated(self):
|
||||
classification = sbr.classify_active_worktree_binding(
|
||||
active_value=os.getcwd(),
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="author",
|
||||
)
|
||||
self.assertEqual(
|
||||
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
|
||||
)
|
||||
|
||||
def test_apply_recovery_clears_env_only_for_authorized_plan(self):
|
||||
env = {"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"}
|
||||
plan = sbr.plan_recovery(
|
||||
sbr.classify_active_worktree_binding(
|
||||
active_value=env["GITEA_ACTIVE_WORKTREE"],
|
||||
boot_inherited=True,
|
||||
path_exists=False,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
)
|
||||
applied = sbr.apply_recovery(plan, env=env)
|
||||
self.assertTrue(applied["performed"])
|
||||
self.assertNotIn("GITEA_ACTIVE_WORKTREE", env)
|
||||
self.assertIn("review-pr-654", applied["cleared_value"])
|
||||
|
||||
def test_apply_recovery_refuses_unauthorized_plan(self):
|
||||
env = {"GITEA_ACTIVE_WORKTREE": os.getcwd()}
|
||||
plan = sbr.plan_recovery(
|
||||
sbr.classify_active_worktree_binding(
|
||||
active_value=env["GITEA_ACTIVE_WORKTREE"],
|
||||
boot_inherited=True,
|
||||
path_exists=True,
|
||||
role_kind="reviewer",
|
||||
)
|
||||
)
|
||||
applied = sbr.apply_recovery(plan, env=env)
|
||||
self.assertFalse(applied["performed"])
|
||||
self.assertIn("GITEA_ACTIVE_WORKTREE", env)
|
||||
|
||||
|
||||
class TestSafeExpiryDiagnosis(unittest.TestCase):
|
||||
"""Scenario 5: canonical expiry flips wait into acquire/guarded cleanup."""
|
||||
|
||||
def _diagnose(self, comments, **kwargs):
|
||||
defaults = dict(
|
||||
pr_number=PR,
|
||||
current_session_id=None,
|
||||
current_reviewer_identity="fresh-reviewer",
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return leases.diagnose_reviewer_pr_lease_handoff(comments, **defaults)
|
||||
|
||||
def setUp(self):
|
||||
leases._SESSION_LEASE = None
|
||||
|
||||
def test_pre_expiry_superseded_no_terminal_stays_wait(self):
|
||||
diagnosis = self._diagnose(_fresh_superseded_comments())
|
||||
self.assertEqual(
|
||||
diagnosis["classification"], "ambiguous_conflicting_evidence"
|
||||
)
|
||||
self.assertEqual(diagnosis["next_action"], "wait")
|
||||
|
||||
def test_post_expiry_without_orphan_evidence_unlocks_acquire(self):
|
||||
diagnosis = self._diagnose(_expired_superseded_comments())
|
||||
self.assertEqual(
|
||||
diagnosis["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertEqual(diagnosis["next_action"], "acquire")
|
||||
|
||||
def test_post_expiry_with_orphan_evidence_offers_guarded_cleanup(self):
|
||||
diagnosis = self._diagnose(
|
||||
_expired_superseded_comments(),
|
||||
owner_process_alive=False,
|
||||
worktree_clean=True,
|
||||
worktree_exists=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
diagnosis["classification"], "orphaned_expired_superseded_head"
|
||||
)
|
||||
self.assertEqual(
|
||||
diagnosis["next_action"], "cleanup_obsolete_reviewer_comment_lease"
|
||||
)
|
||||
|
||||
def test_expired_lease_no_longer_blocks_fresh_acquisition(self):
|
||||
assessment = leases.assess_acquire_lease(
|
||||
_expired_superseded_comments(),
|
||||
pr_number=PR,
|
||||
reviewer_identity="fresh-reviewer",
|
||||
profile="prgs-reviewer",
|
||||
session_id="99999-fresh",
|
||||
repo=REPO,
|
||||
issue_number=ISSUE,
|
||||
worktree="branches/review-pr-701-fresh",
|
||||
candidate_head=NEW_HEAD,
|
||||
target_branch="master",
|
||||
target_branch_sha="2" * 40,
|
||||
)
|
||||
self.assertTrue(assessment.get("acquire_allowed"))
|
||||
|
||||
def test_unparseable_expiry_fails_closed_in_cleanup(self):
|
||||
comment = _lease_comment(
|
||||
last_activity=_now() - timedelta(hours=3), expires_at=None
|
||||
)
|
||||
comment["body"] = comment["body"].replace(
|
||||
"expires_at: ", "expires_at: not-a-timestamp"
|
||||
)
|
||||
result = leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||
[comment],
|
||||
pr_number=PR,
|
||||
current_head_sha=NEW_HEAD,
|
||||
formal_reviews=[],
|
||||
repo=REPO,
|
||||
expected_repo=REPO,
|
||||
requesting_session_id="fresh-controller",
|
||||
controller_recovery_authorized=True,
|
||||
worktree_exists=True,
|
||||
worktree_clean=True,
|
||||
owner_process_alive=False,
|
||||
)
|
||||
self.assertFalse(result["cleanup_allowed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -274,11 +274,17 @@ class TestIssue714SessionContextImmutability(unittest.TestCase):
|
||||
self.assertIn("reason", res)
|
||||
self.assertIn("exact_safe_next_action", res)
|
||||
self.assertIn("activate_profile", res["exact_safe_next_action"])
|
||||
# Unknown task still fail closed
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server.gitea_resolve_task_capability(
|
||||
task="reopen_issue", remote="dadeschools"
|
||||
)
|
||||
# Unknown task still fail closed (structured denial after #723;
|
||||
# never raises into internal_error and never substitutes profile).
|
||||
unknown = mcp_server.gitea_resolve_task_capability(
|
||||
task="reopen_issue", remote="dadeschools"
|
||||
)
|
||||
self.assertFalse(unknown.get("allowed_in_current_session"))
|
||||
self.assertTrue(unknown.get("stop_required"))
|
||||
self.assertEqual(unknown.get("reason_code"), "unknown_task")
|
||||
self.assertEqual(unknown.get("mutation_performed"), False)
|
||||
self.assertEqual(unknown.get("active_profile"), "mdcps-reviewer")
|
||||
self.assertNotEqual(unknown.get("active_profile"), "prgs-author")
|
||||
|
||||
def test_identity_mismatch_blocks_mutation(self):
|
||||
"""Profile expects 913443 but authenticated as jcwalker3."""
|
||||
@@ -604,5 +610,136 @@ class TestSessionContextTestBoundaryIsolation(unittest.TestCase):
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
|
||||
class TestIssue714MergeCoexistenceWithMaster(unittest.TestCase):
|
||||
"""Conflict-resolution regressions after merging advanced master into #715.
|
||||
|
||||
Preserves:
|
||||
- #714 immutable session / no auto profile substitution
|
||||
- #685 side-effect-free resolve (auto_recover=False)
|
||||
- #709 actor identity helper from master
|
||||
"""
|
||||
|
||||
def test_auto_switch_helpers_remain_fail_closed(self):
|
||||
self.assertFalse(mcp_server._try_auto_switch_for_operation("gitea.pr.review"))
|
||||
self.assertFalse(
|
||||
mcp_server._try_auto_switch_for_operation(
|
||||
"gitea.issue.comment", host="gitea.prgs.cc"
|
||||
)
|
||||
)
|
||||
# Active profile is prgs-author; cannot match mdcps review permission
|
||||
# and must not switch.
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"get_profile",
|
||||
return_value={
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.read", "gitea.issue.create"],
|
||||
"forbidden_operations": ["gitea.pr.approve"],
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"context": "prgs",
|
||||
},
|
||||
):
|
||||
matched = mcp_server._ensure_matching_profile(
|
||||
"gitea.pr.approve", "reviewer", remote="prgs"
|
||||
)
|
||||
self.assertIsNone(matched)
|
||||
self.assertNotEqual(
|
||||
gitea_config.selected_profile_name() or "prgs-author",
|
||||
"mdcps-reviewer",
|
||||
)
|
||||
|
||||
def test_authenticated_actor_helper_survives_merge(self):
|
||||
"""Master #709 F7 actor identity coexists with #714 immutability."""
|
||||
self.assertTrue(hasattr(mcp_server, "_authenticated_actor"))
|
||||
with patch(
|
||||
"mcp_server.get_auth_header", return_value={"Authorization": "token x"}
|
||||
), patch(
|
||||
"mcp_server.api_request",
|
||||
return_value={"id": 42, "login": "sysadmin"},
|
||||
):
|
||||
mcp_server._ACTOR_IDENTITY_CACHE.clear()
|
||||
actor = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||
self.assertEqual(actor.get("user_id"), 42)
|
||||
self.assertEqual(actor.get("login"), "sysadmin")
|
||||
# Cached; second call does not re-request
|
||||
with patch("mcp_server.api_request") as mock_api:
|
||||
actor2 = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||
mock_api.assert_not_called()
|
||||
self.assertEqual(actor2.get("user_id"), 42)
|
||||
|
||||
def test_resolve_still_report_only_after_master_merge(self):
|
||||
"""#685: resolve path must not pass auto_recover=True after conflict merge."""
|
||||
allowed = [
|
||||
"gitea.read",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
]
|
||||
profile = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"context": "prgs",
|
||||
}
|
||||
config = {
|
||||
"contexts": {
|
||||
"prgs": {
|
||||
"enabled": True,
|
||||
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"role": "author",
|
||||
"allowed_operations": allowed,
|
||||
"forbidden_operations": [],
|
||||
"base_url": "https://gitea.prgs.cc",
|
||||
"context": "prgs",
|
||||
}
|
||||
},
|
||||
}
|
||||
fake_binding = {
|
||||
"classification": "unbound",
|
||||
"active_worktree": None,
|
||||
"reasons": [],
|
||||
}
|
||||
with patch.dict(
|
||||
os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False
|
||||
), patch.object(
|
||||
mcp_server, "get_profile", return_value=profile
|
||||
), patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=config
|
||||
), patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="jcwalker3"
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"_assess_stale_active_binding",
|
||||
return_value=fake_binding,
|
||||
) as mock_assess, patch.object(
|
||||
mcp_server, "record_preflight_check", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "record_mutation_authority", return_value=None
|
||||
), patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
):
|
||||
result = mcp_server.gitea_resolve_task_capability(
|
||||
task="create_issue", remote="prgs"
|
||||
)
|
||||
mock_assess.assert_called()
|
||||
for call in mock_assess.call_args_list:
|
||||
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||
self.assertEqual(result.get("mutation_performed"), False)
|
||||
# Session context must not have been rewritten by resolve
|
||||
# (report-only; any prior binding stays; unbound stays unbound unless
|
||||
# seed-on-read path is intentional — resolve must not activate peers).
|
||||
self.assertNotEqual(result.get("active_profile"), "mdcps-reviewer")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
"""#720: Expired old-head KIND_DECISION_LOCK must not block fresh review.
|
||||
|
||||
Reproduction shape (PR #616):
|
||||
* REQUEST_CHANGES terminal at head A
|
||||
* open PR advanced to head B
|
||||
* durable decision lock age > default 4h TTL
|
||||
* fresh_review_on_current_head_allowed is true but unreachable under TTL-first reject
|
||||
* mark_final fails with "session state expired after 4h"
|
||||
* assessment may report "no lock" while the file remains on disk
|
||||
|
||||
Security invariants preserved:
|
||||
* same-head second terminal remains fail-closed (#332/#620)
|
||||
* REQUEST_CHANGES is never treated as approval
|
||||
* historical mutations remain on the ledger
|
||||
* merged/closed moot cleanup still allowed (#594)
|
||||
* irrecoverable provenance still requires #709 authorization
|
||||
* ordinary profiles do not gain gitea.decision_lock.irrecoverable_recovery
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import mcp_session_state as ss
|
||||
import mcp_server
|
||||
import stale_review_decision_lock as srdl
|
||||
import task_capability_map
|
||||
|
||||
HEAD_A = "a0fffae576673ba7df7456b32e6aec916581bdfb"
|
||||
HEAD_B = "a6a2243aad9c3e385fc70509f4941a0f0ec33162"
|
||||
HEAD_SAME = HEAD_A
|
||||
|
||||
RC_616_A = {
|
||||
"pr_number": 616,
|
||||
"action": "request_changes",
|
||||
"review_id": 443,
|
||||
"review_state": "request_changes",
|
||||
"head_sha": HEAD_A,
|
||||
}
|
||||
|
||||
|
||||
def _lock(mutations=None, **kwargs):
|
||||
base = {
|
||||
"task": "review_pr",
|
||||
"kind": ss.KIND_DECISION_LOCK,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"session_pid": os.getpid(),
|
||||
"session_profile": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"final_review_decision_ready": False,
|
||||
"ready_pr_number": kwargs.get("ready_pr"),
|
||||
"ready_action": kwargs.get("ready_action"),
|
||||
"ready_expected_head_sha": kwargs.get("ready_head"),
|
||||
"ready_remote": "prgs" if kwargs.get("ready_pr") else None,
|
||||
"ready_org": "Scaled-Tech-Consulting" if kwargs.get("ready_pr") else None,
|
||||
"ready_repo": "Gitea-Tools" if kwargs.get("ready_pr") else None,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": False,
|
||||
"correction_reason": None,
|
||||
}
|
||||
return base
|
||||
|
||||
|
||||
def _open_pr(pr_number=616, head=HEAD_B, merged=False, closed=False):
|
||||
state = "closed" if closed or merged else "open"
|
||||
return {
|
||||
"number": pr_number,
|
||||
"state": state,
|
||||
"merged": merged,
|
||||
"merged_at": "2026-07-16T12:00:00Z" if merged else None,
|
||||
"merge_commit_sha": "m" * 40 if merged else None,
|
||||
"head": {"sha": head},
|
||||
}
|
||||
|
||||
|
||||
def _no_lease():
|
||||
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
|
||||
|
||||
def _feedback(blocking=False, stale=True):
|
||||
return {
|
||||
"success": True,
|
||||
"has_blocking_change_requests": blocking,
|
||||
"review_feedback_stale": stale,
|
||||
"current_head_sha": HEAD_B,
|
||||
}
|
||||
|
||||
|
||||
def _age_lock_payload(lock: dict, hours: float = 5.0) -> dict:
|
||||
"""Rewrite timestamps to *hours* ago (simulates durable age without touching prod)."""
|
||||
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
out = dict(lock)
|
||||
out["recorded_at"] = aged
|
||||
out["updated_at"] = aged
|
||||
return out
|
||||
|
||||
|
||||
class TestIssue720ExpiredDecisionLockLifecycle(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
ss.STATE_DIR_ENV: self._tmp.name,
|
||||
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
||||
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self.env.start()
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
import review_workflow_load
|
||||
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||
mcp_server.gitea_load_review_workflow()
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
import review_workflow_load
|
||||
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
self.env.stop()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _persist_aged_lock(self, mutations, hours: float = 5.0, **kwargs):
|
||||
"""Write decision lock aged > TTL to the temp durable store (test-only)."""
|
||||
payload = _age_lock_payload(_lock(mutations, **kwargs), hours=hours)
|
||||
saved = ss.save_state(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
payload=payload,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
profile_identity="prgs-reviewer",
|
||||
)
|
||||
# save_state refreshes updated_at; re-age the on-disk envelope for TTL tests.
|
||||
path = ss.state_file_path(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
import json
|
||||
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
envelope = json.load(fh)
|
||||
envelope["recorded_at"] = aged
|
||||
envelope["updated_at"] = aged
|
||||
if isinstance(envelope.get("payload"), dict):
|
||||
envelope["payload"]["recorded_at"] = aged
|
||||
envelope["payload"]["updated_at"] = aged
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(envelope, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
# Drop memory so subsequent loads hit durable store.
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
return saved
|
||||
|
||||
def _mark(self, pr, action, head, feedback=None):
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_get_pr_review_feedback",
|
||||
return_value=feedback or _feedback(blocking=False, stale=True),
|
||||
), patch.object(
|
||||
mcp_server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={"eligible": True, "head_sha": head},
|
||||
), patch.object(
|
||||
mcp_server.mcp_daemon_guard,
|
||||
"assert_sanctioned_mutation_runtime",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
mcp_server.mcp_daemon_guard,
|
||||
"assert_no_direct_import_bypass",
|
||||
return_value=None,
|
||||
):
|
||||
return mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=pr,
|
||||
action=action,
|
||||
expected_head_sha=head,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
|
||||
# --- AC regression matrix ---
|
||||
|
||||
def test_under_four_hours_fresh_review_at_b_allowed(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=1.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(res.get("marked_ready"), res)
|
||||
|
||||
def test_over_four_hours_fresh_review_at_b_still_allowed(self):
|
||||
"""Primary #720 defect: age > TTL must not block head-B mark_final."""
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
# Prove durable load is possible for recovery-critical decision locks.
|
||||
loaded = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
loaded,
|
||||
"expired KIND_DECISION_LOCK must remain loadable (not generic TTL cache)",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(
|
||||
res.get("marked_ready"),
|
||||
f"expected mark_ready on head B after >4h; got {res}",
|
||||
)
|
||||
reasons = " ".join(res.get("reasons") or [])
|
||||
self.assertNotIn("session state expired", reasons)
|
||||
|
||||
def test_historical_review_at_a_preserved_and_stale(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(res.get("marked_ready"), res)
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
self.assertIsNotNone(lock)
|
||||
hist = [m for m in lock["live_mutations"] if m.get("review_id") == 443]
|
||||
self.assertEqual(len(hist), 1)
|
||||
self.assertEqual(hist[0]["head_sha"], HEAD_A)
|
||||
self.assertEqual(hist[0]["action"], "request_changes")
|
||||
a = srdl.assess_stale_review_decision_lock(
|
||||
lock, pr_live=_open_pr(616, HEAD_B)
|
||||
)
|
||||
self.assertTrue(a["stale_by_head"])
|
||||
self.assertTrue(a["fresh_review_on_current_head_allowed"])
|
||||
self.assertEqual(a["locked_head_sha"], HEAD_A)
|
||||
|
||||
def test_no_reuse_approval_from_head_a(self):
|
||||
"""REQUEST_CHANGES at A is never an approval credential for B."""
|
||||
self._persist_aged_lock([RC_616_A], hours=5.0)
|
||||
lock = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
last = srdl.last_terminal_mutation(lock)
|
||||
self.assertEqual(last.get("action"), "request_changes")
|
||||
self.assertNotEqual(last.get("action"), "approve")
|
||||
# Gate must still require a new mark/submit for head B; prior RC is not approve.
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||
616, "mark_ready", expected_head_sha=HEAD_B
|
||||
)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
def test_second_terminal_mutation_at_b_same_run_blocked(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_B)
|
||||
self.assertTrue(res.get("marked_ready"), res)
|
||||
# Record terminal approve at B (same run).
|
||||
lock = mcp_server._load_review_decision_lock()
|
||||
lock["final_review_decision_ready"] = True
|
||||
lock["ready_pr_number"] = 616
|
||||
lock["ready_action"] = "approve"
|
||||
lock["ready_expected_head_sha"] = HEAD_B
|
||||
mcp_server._save_review_decision_lock(lock)
|
||||
mcp_server.record_live_review_mutation(616, "approve", review_id=999)
|
||||
# Second terminal at same head B must hard-stop.
|
||||
hard = mcp_server.terminal_review_hard_stop_reasons(
|
||||
616, "mark_ready", expected_head_sha=HEAD_B
|
||||
)
|
||||
self.assertTrue(hard)
|
||||
res2 = self._mark(616, "approve", HEAD_B)
|
||||
self.assertFalse(res2.get("marked_ready"))
|
||||
|
||||
def test_open_pr_same_head_expired_still_fail_closed(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_SAME)
|
||||
self.assertFalse(res.get("marked_ready"), res)
|
||||
self.assertTrue(
|
||||
any("#332" in r or "already consumed" in r for r in (res.get("reasons") or [])),
|
||||
res,
|
||||
)
|
||||
|
||||
def test_merged_pr_moot_cleanup_still_allowed(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
lock = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
a = srdl.assess_stale_review_decision_lock(
|
||||
lock, pr_live=_open_pr(616, HEAD_A, merged=True)
|
||||
)
|
||||
self.assertTrue(a["is_moot"])
|
||||
self.assertTrue(a["cleanup_allowed"])
|
||||
self.assertFalse(a["fresh_review_on_current_head_allowed"])
|
||||
|
||||
def test_assessment_reports_expired_old_head_not_absent(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
# Disk presence + load after lifecycle fix.
|
||||
path = ss.state_file_path(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
loaded = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
self.assertIsNotNone(loaded)
|
||||
inspect = ss.inspect_state_envelope(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
self.assertTrue(inspect.get("on_disk"))
|
||||
self.assertTrue(inspect.get("has_payload"))
|
||||
self.assertTrue(inspect.get("recovery_critical") or inspect.get("ttl_exempt"))
|
||||
self.assertTrue(inspect.get("age_hours", 0) >= 4.0)
|
||||
a = srdl.assess_stale_review_decision_lock(
|
||||
loaded, pr_live=_open_pr(616, HEAD_B)
|
||||
)
|
||||
self.assertTrue(a["has_lock"])
|
||||
self.assertNotIn("no review decision lock present", " ".join(a["reasons"]))
|
||||
self.assertTrue(a["stale_by_head"])
|
||||
self.assertEqual(a["last_terminal_action"], "request_changes")
|
||||
|
||||
def test_existing_serialized_records_compatible(self):
|
||||
"""Pre-fix ledgers without recovery_critical flag still load via kind."""
|
||||
payload = _age_lock_payload(_lock([RC_616_A]), hours=8.0)
|
||||
payload.pop("recovery_critical", None)
|
||||
# Manually write pre-#720-shaped envelope (kind only on envelope).
|
||||
path = ss.state_file_path(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
import json
|
||||
|
||||
os.makedirs(self._tmp.name, exist_ok=True)
|
||||
aged = payload["recorded_at"]
|
||||
envelope = {
|
||||
"kind": ss.KIND_DECISION_LOCK,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
"recorded_at": aged,
|
||||
"updated_at": aged,
|
||||
"writer_pid": os.getpid(),
|
||||
"payload": payload,
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(envelope, fh, indent=2, sort_keys=True)
|
||||
fh.write("\n")
|
||||
loaded = ss.load_state(
|
||||
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||
)
|
||||
self.assertIsNotNone(loaded)
|
||||
self.assertEqual(loaded["live_mutations"][0]["review_id"], 443)
|
||||
|
||||
def test_decision_lock_is_recovery_critical_kind(self):
|
||||
self.assertIn(ss.KIND_DECISION_LOCK, ss.RECOVERY_CRITICAL_KINDS)
|
||||
|
||||
def test_workflow_load_still_ttl_expires(self):
|
||||
"""Do not make every session-state kind permanently TTL-exempt."""
|
||||
aged = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
rec = {
|
||||
"kind": ss.KIND_WORKFLOW_LOAD,
|
||||
"recorded_at": aged,
|
||||
"updated_at": aged,
|
||||
"profile_identity": "prgs-reviewer",
|
||||
"session_profile_lock": "prgs-reviewer",
|
||||
}
|
||||
reasons = ss.identity_match_reasons(
|
||||
rec, profile_identity="prgs-reviewer"
|
||||
)
|
||||
self.assertTrue(any("expired" in r for r in reasons), reasons)
|
||||
|
||||
def test_irrecoverable_permission_not_on_ordinary_profiles(self):
|
||||
perm = "gitea.decision_lock.irrecoverable_recovery"
|
||||
# Capability map must not map ordinary author/reviewer/merger tasks to it.
|
||||
for task in (
|
||||
"create_issue",
|
||||
"comment_issue",
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"lock_issue",
|
||||
"create_pr",
|
||||
):
|
||||
req = task_capability_map.required_permission(task)
|
||||
self.assertNotEqual(req, perm, msg=task)
|
||||
|
||||
def test_structured_error_when_same_head_expired(self):
|
||||
self._persist_aged_lock(
|
||||
[RC_616_A],
|
||||
hours=5.0,
|
||||
ready_pr=616,
|
||||
ready_head=HEAD_A,
|
||||
ready_action="request_changes",
|
||||
)
|
||||
res = self._mark(616, "approve", HEAD_A)
|
||||
self.assertFalse(res.get("marked_ready"))
|
||||
reasons = res.get("reasons") or []
|
||||
self.assertTrue(reasons)
|
||||
blob = " ".join(reasons)
|
||||
# Actionable recovery text from hard-stop (not generic internal_error).
|
||||
self.assertIn("#332", blob)
|
||||
self.assertTrue(
|
||||
"#620" in blob or "head moved" in blob or "new expected_head_sha" in blob
|
||||
or "already consumed" in blob
|
||||
)
|
||||
self.assertNotIn("internal_error", blob.lower())
|
||||
|
||||
|
||||
class TestIssue720InspectEnvelope(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
ss.STATE_DIR_ENV: self._tmp.name,
|
||||
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self.env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.env.stop()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_inspect_missing_file(self):
|
||||
info = ss.inspect_state_envelope(
|
||||
kind=ss.KIND_DECISION_LOCK,
|
||||
profile_identity="prgs-reviewer",
|
||||
state_dir=self._tmp.name,
|
||||
)
|
||||
self.assertFalse(info["on_disk"])
|
||||
self.assertFalse(info["has_payload"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1471,10 +1471,16 @@ class TestReviewPR(unittest.TestCase):
|
||||
class TestDeleteBranch(unittest.TestCase):
|
||||
|
||||
DELETE_PROFILE = {
|
||||
"profile_name": "test-deleter",
|
||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||
"profile_name": "test-author-deleter",
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-deleter",
|
||||
"audit_label": "test-author-deleter",
|
||||
}
|
||||
|
||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||
@@ -2584,15 +2590,18 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
lock = _load_review_decision_lock() or {}
|
||||
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
||||
_save_review_decision_lock(lock)
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
)
|
||||
with patch("mcp_server.api_request", return_value={"id": 9001}):
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
self.assertTrue(r["authorized"])
|
||||
lock = _load_review_decision_lock()
|
||||
self.assertTrue(lock["correction_authorized"])
|
||||
self.assertEqual(lock["correction_pr_number"], 8)
|
||||
|
||||
def test_authorize_review_correction_mismatch(self):
|
||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||
@@ -2606,6 +2615,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
|
||||
@@ -2616,10 +2626,22 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="request_changes",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("prior review state" in x for x in r["reasons"]))
|
||||
|
||||
# Cross-PR unlock refused (#693)
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="unlock other pr",
|
||||
operator_authorized=True,
|
||||
target_pr_number=692,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("different PR" in x for x in r["reasons"]))
|
||||
|
||||
def test_authorize_review_correction_requires_operator_auth(self):
|
||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||
lock = _load_review_decision_lock() or {}
|
||||
@@ -2632,6 +2654,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=False,
|
||||
target_pr_number=8,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
|
||||
@@ -2689,6 +2712,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
prior_review_state="approve",
|
||||
reason="operator approved correcting mistaken approve",
|
||||
operator_authorized=True,
|
||||
target_pr_number=8,
|
||||
)
|
||||
_mark_request_changes_ready(remote="prgs")
|
||||
second = gitea_submit_pr_review(
|
||||
|
||||
@@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
||||
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||
|
||||
@patch("threading.Thread")
|
||||
@patch("os.utime")
|
||||
@patch("os.path.exists")
|
||||
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
|
||||
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
|
||||
mock_exists.return_value = True
|
||||
gitea_mcp_server._restart_triggered = False
|
||||
|
||||
# Ensure we are not skipped in test mode for testing purposes
|
||||
with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False):
|
||||
gitea_mcp_server._trigger_mcp_auto_restart()
|
||||
|
||||
mock_utime.assert_called_once_with("/tmp/mcp_config.json", None)
|
||||
mock_thread.assert_called_once()
|
||||
self.assertTrue(gitea_mcp_server._restart_triggered)
|
||||
def test_auto_restart_helper_removed_from_read_only_path(self):
|
||||
"""#685: config-touch / os._exit self-recovery is no longer on the server."""
|
||||
self.assertFalse(
|
||||
hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"),
|
||||
"_trigger_mcp_auto_restart must not remain (side-effect-free resolver)",
|
||||
)
|
||||
self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Merger lease acquisition + capability resolution tests (#718, #723)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
import reviewer_pr_lease as leases
|
||||
import task_capability_map as tcm
|
||||
|
||||
|
||||
HEAD = "a" * 40
|
||||
LIVE_HEAD = HEAD
|
||||
|
||||
|
||||
class TestMergerLeaseAcquireTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "merger"
|
||||
mcp_server._preflight_resolved_task = "acquire_merger_pr_lease"
|
||||
leases.clear_session_lease()
|
||||
|
||||
def tearDown(self):
|
||||
leases.clear_session_lease()
|
||||
|
||||
def _merger_profile(self, **extra):
|
||||
p = {
|
||||
"username": "merger-user",
|
||||
"profile_name": "prgs-merger",
|
||||
"role": "merger",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
}
|
||||
p.update(extra)
|
||||
return p
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||
@patch("gitea_mcp_server.get_profile")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_lease_success(
|
||||
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||
):
|
||||
mock_profile.return_value = self._merger_profile()
|
||||
mock_api.side_effect = [
|
||||
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
||||
{"id": 456},
|
||||
]
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
candidate_head=HEAD,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(res["success"], res)
|
||||
self.assertTrue(res["acquired"])
|
||||
self.assertEqual(res["comment_id"], 456)
|
||||
self.assertEqual(res["session_id"], "merger-session")
|
||||
self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools")
|
||||
body = mock_api.call_args_list[-1][0][3]["body"]
|
||||
self.assertIn("reviewer_identity: merger-user", body)
|
||||
self.assertIn("profile: prgs-merger", body)
|
||||
session_lease = leases._SESSION_LEASE
|
||||
self.assertIsNotNone(session_lease)
|
||||
provenance = session_lease.get("lease_provenance") or {}
|
||||
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||
@patch("gitea_mcp_server.get_profile")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_lease_fails_if_active_exists(
|
||||
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||
):
|
||||
mock_profile.return_value = self._merger_profile()
|
||||
active_body = leases.format_lease_body(
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
pr_number=718,
|
||||
issue_number=None,
|
||||
reviewer_identity="other-user",
|
||||
profile="prgs-reviewer",
|
||||
session_id="other-session",
|
||||
worktree="branches/review-1",
|
||||
phase="claimed",
|
||||
candidate_head=HEAD,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
last_activity=datetime.now(timezone.utc),
|
||||
)
|
||||
_comments.return_value = [
|
||||
{"id": 1, "body": active_body, "user": {"login": "other-user"}}
|
||||
]
|
||||
mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}}
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
candidate_head=HEAD,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["acquired"])
|
||||
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
|
||||
self.assertEqual(
|
||||
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
||||
)
|
||||
self.assertIsNone(leases._SESSION_LEASE)
|
||||
|
||||
@patch("gitea_mcp_server.get_profile")
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile):
|
||||
mock_profile.return_value = self._merger_profile()
|
||||
_verify.side_effect = RuntimeError("Branches-only mutation guard")
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
candidate_head=HEAD,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertIn("Branches-only mutation guard", res["reasons"][0])
|
||||
self.assertIsNone(leases._SESSION_LEASE)
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||
@patch("gitea_mcp_server.get_profile")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify(
|
||||
self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||
):
|
||||
"""Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet)."""
|
||||
mock_profile.return_value = self._merger_profile()
|
||||
mock_api.side_effect = [
|
||||
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
||||
{"id": 789},
|
||||
]
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
candidate_head=HEAD,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(res["success"], res)
|
||||
kwargs = mock_verify.call_args.kwargs
|
||||
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
|
||||
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
|
||||
self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease")
|
||||
|
||||
@patch("gitea_mcp_server.get_profile")
|
||||
def test_acquire_merger_requires_candidate_head(self, mock_profile):
|
||||
mock_profile.return_value = self._merger_profile()
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
candidate_head=None,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertTrue(any("candidate_head is required" in r for r in res["reasons"]))
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||
@patch("gitea_mcp_server.get_profile")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_head_mismatch(
|
||||
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||
):
|
||||
mock_profile.return_value = self._merger_profile()
|
||||
mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}}
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
candidate_head=HEAD,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertTrue(any("does not match live PR head" in r for r in res["reasons"]))
|
||||
self.assertIsNone(leases._SESSION_LEASE)
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||
@patch("gitea_mcp_server.get_profile")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_fails_closed_when_live_head_unresolvable(
|
||||
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||
):
|
||||
"""Empty/missing live head must not silently proceed past exact-head gate."""
|
||||
mock_profile.return_value = self._merger_profile()
|
||||
# PR payload present but head.sha missing / empty / non-dict.
|
||||
mock_api.return_value = {"state": "open", "head": {}}
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
candidate_head=HEAD,
|
||||
)
|
||||
self.assertFalse(res["success"], res)
|
||||
self.assertFalse(res.get("acquired"), res)
|
||||
self.assertTrue(
|
||||
any("live PR head could not be resolved" in r for r in res["reasons"]),
|
||||
res,
|
||||
)
|
||||
self.assertIsNone(res.get("live_head"))
|
||||
self.assertEqual(res.get("candidate_head"), HEAD)
|
||||
self.assertEqual(
|
||||
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
||||
)
|
||||
self.assertIsNone(leases._SESSION_LEASE)
|
||||
|
||||
# Completely empty GET /pulls body also fails closed.
|
||||
mock_api.return_value = {}
|
||||
res2 = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
candidate_head=HEAD,
|
||||
)
|
||||
self.assertFalse(res2["success"], res2)
|
||||
self.assertTrue(
|
||||
any("live PR head could not be resolved" in r for r in res2["reasons"]),
|
||||
res2,
|
||||
)
|
||||
self.assertIsNone(leases._SESSION_LEASE)
|
||||
|
||||
@patch("gitea_mcp_server._profile_operation_gate")
|
||||
def test_author_denied_merge_permission(self, mock_gate):
|
||||
"""Author profile lacks gitea.pr.merge → fail closed before mutation."""
|
||||
def gate(op):
|
||||
if op == "gitea.pr.merge":
|
||||
return [f"profile lacks {op}"]
|
||||
return None
|
||||
mock_gate.side_effect = gate
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
candidate_head=HEAD,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
||||
|
||||
@patch("gitea_mcp_server._profile_operation_gate")
|
||||
def test_reviewer_denied_merge_permission(self, mock_gate):
|
||||
def gate(op):
|
||||
if op == "gitea.pr.merge":
|
||||
return [f"profile lacks {op}"]
|
||||
return None
|
||||
mock_gate.side_effect = gate
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
candidate_head=HEAD,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
||||
|
||||
|
||||
class TestCapabilityMapLeaseTasks(unittest.TestCase):
|
||||
def test_merger_acquire_map_entries(self):
|
||||
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
||||
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
||||
self.assertEqual(tcm.required_role(task), "merger")
|
||||
|
||||
def test_reviewer_acquire_map_entries(self):
|
||||
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
||||
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
||||
self.assertEqual(tcm.required_role(task), "reviewer")
|
||||
|
||||
def test_adopt_merger_aliases(self):
|
||||
for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"):
|
||||
self.assertEqual(tcm.required_role(task), "merger")
|
||||
|
||||
|
||||
class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._process_boot_head_sha = None
|
||||
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||
mcp_server.capability_stop_terminal.clear()
|
||||
|
||||
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||
@patch.object(mcp_server, "get_profile")
|
||||
@patch.object(mcp_server.gitea_config, "load_config")
|
||||
def test_resolve_acquire_reviewer_authorized(
|
||||
self, mock_cfg, mock_profile, *_mocks
|
||||
):
|
||||
mock_profile.return_value = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
mock_cfg.return_value = {
|
||||
"profiles": {
|
||||
"prgs-reviewer": {
|
||||
"role": "reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.pr.comment",
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False):
|
||||
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
||||
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||
self.assertEqual(res["required_role_kind"], "reviewer", res)
|
||||
self.assertEqual(
|
||||
res["required_operation_permission"], "gitea.pr.comment", res
|
||||
)
|
||||
self.assertTrue(res.get("allowed_in_current_session"), res)
|
||||
|
||||
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||
@patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3")
|
||||
@patch.object(mcp_server, "get_profile")
|
||||
@patch.object(mcp_server.gitea_config, "load_config")
|
||||
def test_resolve_acquire_reviewer_author_denied(
|
||||
self, mock_cfg, mock_profile, *_mocks
|
||||
):
|
||||
mock_profile.return_value = {
|
||||
"profile_name": "prgs-author",
|
||||
"role": "author",
|
||||
"allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
mock_cfg.return_value = {
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"role": "author",
|
||||
"allowed_operations": [
|
||||
"gitea.pr.comment",
|
||||
"gitea.branch.push",
|
||||
"gitea.read",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
},
|
||||
"prgs-reviewer": {
|
||||
"role": "reviewer",
|
||||
"allowed_operations": ["gitea.pr.comment", "gitea.pr.review"],
|
||||
"forbidden_operations": [],
|
||||
},
|
||||
}
|
||||
}
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False):
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="acquire_reviewer_pr_lease", remote="prgs"
|
||||
)
|
||||
self.assertFalse(res.get("allowed_in_current_session"), res)
|
||||
self.assertEqual(res["required_role_kind"], "reviewer")
|
||||
guidance = " ".join(res.get("task_role_guidance") or [])
|
||||
self.assertTrue(
|
||||
"cannot perform reviewer" in guidance.lower()
|
||||
or "reviewer" in guidance.lower()
|
||||
or res.get("stop_required"),
|
||||
res,
|
||||
)
|
||||
|
||||
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||
@patch.object(mcp_server, "get_profile")
|
||||
def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks):
|
||||
mock_profile.return_value = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": ["gitea.pr.comment"],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
# Must not raise ValueError into the tool boundary.
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="some_made_up_task", remote="prgs"
|
||||
)
|
||||
self.assertIsInstance(res, dict)
|
||||
self.assertEqual(res.get("reason_code"), "unknown_task", res)
|
||||
self.assertFalse(res.get("allowed_in_current_session"))
|
||||
self.assertTrue(res.get("stop_required"))
|
||||
self.assertIs(res.get("mutation_performed"), False)
|
||||
self.assertNotIn("token", str(res).lower())
|
||||
self.assertNotIn("password", str(res).lower())
|
||||
guidance = " ".join(res.get("task_role_guidance") or [])
|
||||
self.assertIn("Unknown task/action", guidance)
|
||||
|
||||
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||
@patch.object(mcp_server, "get_profile")
|
||||
@patch.object(mcp_server.gitea_config, "load_config")
|
||||
def test_resolve_merger_acquire_aliases(
|
||||
self, mock_cfg, mock_profile, *_mocks
|
||||
):
|
||||
mock_profile.return_value = {
|
||||
"profile_name": "prgs-merger",
|
||||
"role": "merger",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
mock_cfg.return_value = {
|
||||
"profiles": {
|
||||
"prgs-merger": {
|
||||
"role": "merger",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.comment",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False):
|
||||
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
||||
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||
self.assertEqual(res["required_role_kind"], "merger", res)
|
||||
self.assertEqual(
|
||||
res["required_operation_permission"], "gitea.pr.comment", res
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -92,14 +92,19 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
author = prgs_gitea["identities"]["author"]
|
||||
self.assertEqual(author["username"], "jcwalker3")
|
||||
self.assertEqual(author["auth"]["id"], "redacted-author-ref")
|
||||
self.assertEqual(author["allowed_operations"], ["read", "comment"])
|
||||
self.assertEqual(author["forbidden_operations"], ["approve", "merge"])
|
||||
self.assertEqual(
|
||||
author["allowed_operations"], ["gitea.read", "gitea.pr.comment"]
|
||||
)
|
||||
self.assertEqual(
|
||||
author["forbidden_operations"],
|
||||
["gitea.pr.approve", "gitea.pr.merge"],
|
||||
)
|
||||
|
||||
reviewer = prgs_gitea["identities"]["reviewer"]
|
||||
self.assertEqual(reviewer["role"], "reviewer")
|
||||
self.assertEqual(reviewer["username"], "sysadmin")
|
||||
self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref")
|
||||
self.assertIn("merge", reviewer["allowed_operations"])
|
||||
self.assertIn("gitea.pr.merge", reviewer["allowed_operations"])
|
||||
|
||||
def test_alias_generation(self):
|
||||
"""Test that aliases are correctly generated to support old profile names."""
|
||||
@@ -188,7 +193,7 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
self.assertNotIn("token", stdout_output.lower())
|
||||
|
||||
def test_explicit_operations_are_preserved(self):
|
||||
"""Explicit v1 permissions must not be replaced by role defaults."""
|
||||
"""Explicit v1 permissions are canonicalized, not replaced by role defaults."""
|
||||
v1_data = json.loads(json.dumps(self.v1_content))
|
||||
v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"]
|
||||
v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"]
|
||||
@@ -198,8 +203,8 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||
["identities"]["reviewer"]
|
||||
)
|
||||
self.assertEqual(reviewer["allowed_operations"], ["read"])
|
||||
self.assertEqual(reviewer["forbidden_operations"], ["merge"])
|
||||
self.assertEqual(reviewer["allowed_operations"], ["gitea.read"])
|
||||
self.assertEqual(reviewer["forbidden_operations"], ["gitea.pr.merge"])
|
||||
|
||||
def test_inferred_role_defaults_only_when_unambiguous(self):
|
||||
"""Role defaults are allowed only for clear author/reviewer profiles."""
|
||||
@@ -306,6 +311,171 @@ class TestMigrateProfiles(unittest.TestCase):
|
||||
migrate_profiles.main()
|
||||
self.assertEqual(cm.exception.code, 1)
|
||||
|
||||
def test_reconciler_profile_migration(self):
|
||||
"""Legacy reconciler shorthands migrate to valid canonical operations."""
|
||||
import gitea_config
|
||||
import reconciler_profile
|
||||
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-reconciler": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "reconciler-agent",
|
||||
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||
"execution_profile": "prgs-reconciler",
|
||||
"allowed_operations": [
|
||||
"read",
|
||||
"pr.close",
|
||||
"pr.comment",
|
||||
"issue.comment",
|
||||
"issue.close",
|
||||
"gitea.branch.delete",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"merge",
|
||||
"approve",
|
||||
"review",
|
||||
"pr.create",
|
||||
"branch.push",
|
||||
"commit",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
reconciler = (
|
||||
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||
["identities"]["reconciler"]
|
||||
)
|
||||
self.assertEqual(reconciler["role"], "reconciler")
|
||||
allowed = reconciler["allowed_operations"]
|
||||
forbidden = reconciler["forbidden_operations"]
|
||||
# No invalid shorthand remains
|
||||
for bad in ("pr.close", "pr.comment", "issue.close", "read", "merge"):
|
||||
self.assertNotIn(bad, allowed)
|
||||
self.assertNotIn(bad, forbidden)
|
||||
for required in (
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
"gitea.branch.delete",
|
||||
):
|
||||
self.assertIn(required, allowed)
|
||||
# Production loader accepts every allowed op
|
||||
self.assertEqual(
|
||||
gitea_config.normalize_operation(required), required
|
||||
)
|
||||
self.assertEqual(v2_data["aliases"]["prgs-reconciler"], "prgs.gitea.reconciler")
|
||||
assessment = reconciler_profile.assess_reconciler_profile(allowed, forbidden)
|
||||
self.assertTrue(assessment["valid"])
|
||||
self.assertTrue(migrate_profiles.validate_v2_data(v2_data))
|
||||
|
||||
def test_reconciler_profile_defaults(self):
|
||||
"""Reconciler defaults are fully canonical and loader-valid."""
|
||||
import gitea_config
|
||||
import reconciler_profile
|
||||
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-reconciler": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "reconciler-agent",
|
||||
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||
"execution_profile": "prgs-reconciler",
|
||||
}
|
||||
}
|
||||
}
|
||||
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
reconciler = (
|
||||
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||
["identities"]["reconciler"]
|
||||
)
|
||||
self.assertEqual(reconciler["role"], "reconciler")
|
||||
self.assertEqual(
|
||||
reconciler["allowed_operations"],
|
||||
migrate_profiles.RECONCILER_DEFAULT_ALLOWED,
|
||||
)
|
||||
self.assertEqual(
|
||||
reconciler["forbidden_operations"],
|
||||
migrate_profiles.RECONCILER_DEFAULT_FORBIDDEN,
|
||||
)
|
||||
for op in reconciler["allowed_operations"]:
|
||||
self.assertEqual(gitea_config.normalize_operation(op), op)
|
||||
self.assertTrue(op.startswith("gitea."))
|
||||
assessment = reconciler_profile.assess_reconciler_profile(
|
||||
reconciler["allowed_operations"],
|
||||
reconciler["forbidden_operations"],
|
||||
)
|
||||
self.assertTrue(assessment["valid"])
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete", assessment["missing_recommended_operations"]
|
||||
)
|
||||
|
||||
def test_reconciler_migration_idempotent_canonicalize(self):
|
||||
"""Second canonicalize of already-canonical ops is a no-op."""
|
||||
first = migrate_profiles.canonicalize_operations(
|
||||
list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED)
|
||||
)
|
||||
second = migrate_profiles.canonicalize_operations(first)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first, list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED))
|
||||
|
||||
def test_reconciler_missing_required_fails_visibly(self):
|
||||
"""Missing gitea.pr.close after migration fails closed (not silent drop)."""
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-reconciler": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "reconciler-agent",
|
||||
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||
"execution_profile": "prgs-reconciler",
|
||||
"allowed_operations": ["read", "gitea.branch.delete"],
|
||||
"forbidden_operations": ["merge"],
|
||||
}
|
||||
},
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "missing required"):
|
||||
migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
|
||||
def test_unknown_operation_fails_visibly(self):
|
||||
v1_data = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"prgs-author": {
|
||||
"base_url": "redacted-prgs-service",
|
||||
"username": "jcwalker3",
|
||||
"auth": {"type": "keychain", "id": "hidden-author-ref"},
|
||||
"execution_profile": "prgs-author",
|
||||
"allowed_operations": ["read", "not.a.real.op"],
|
||||
"forbidden_operations": ["merge"],
|
||||
}
|
||||
},
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "cannot be canonicalized"):
|
||||
migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||
|
||||
def test_role_inference_author_reviewer_merger_reconciler(self):
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-author", "prgs-author"), "author"
|
||||
)
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-reviewer", "prgs-reviewer"),
|
||||
"reviewer",
|
||||
)
|
||||
self.assertEqual(
|
||||
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
|
||||
"reconciler",
|
||||
)
|
||||
self.assertIsNone(
|
||||
migrate_profiles.infer_role("prgs-merger", "prgs-merger")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""#727 / PR #728: author ownership when tracking issue number != PR number.
|
||||
|
||||
Regression for REQUEST_CHANGES: gitea_update_pr_branch_by_merge must not require
|
||||
issue_number == pr_number. Ownership is proven via linked issue + lock/branch
|
||||
context and fails closed for unrelated issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import issue_lock_store
|
||||
import gitea_mcp_server as mcp
|
||||
|
||||
|
||||
def _live_lock(
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str = "feat/issue-727-pr-sync-status",
|
||||
worktree_path: str = "/tmp/branches/issue-727-pr-sync-status",
|
||||
remote: str = "prgs",
|
||||
org: str = "Scaled-Tech-Consulting",
|
||||
repo: str = "Gitea-Tools",
|
||||
) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": worktree_path,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"acquired_at": now.isoformat(),
|
||||
"expires_at": (now + timedelta(hours=2)).isoformat(),
|
||||
"owner_pid": os.getpid(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="gitea-issue-locks-")
|
||||
self.lock_dir = self._tmp.name
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir
|
||||
|
||||
def tearDown(self):
|
||||
# Clear session pointer for this process.
|
||||
try:
|
||||
ptr = issue_lock_store.session_pointer_path(self.lock_dir)
|
||||
if os.path.isfile(ptr):
|
||||
os.unlink(ptr)
|
||||
except Exception:
|
||||
pass
|
||||
os.environ.pop("GITEA_ISSUE_LOCK_DIR", None)
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_issue_727_pr_728_session_lock_accepted(self):
|
||||
"""Tracking issue #727 lock is valid ownership for PR #728."""
|
||||
lock = _live_lock(issue_number=727)
|
||||
issue_lock_store.bind_session_lock(lock)
|
||||
result = mcp._prove_author_ownership_for_pr(
|
||||
pr_number=728,
|
||||
pr_title="feat: pr sync",
|
||||
pr_body="Closes #727\n\nNative PR sync lifecycle.",
|
||||
source_branch="feat/issue-727-pr-sync-status",
|
||||
remote="prgs",
|
||||
host=None,
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path=lock["worktree_path"],
|
||||
)
|
||||
self.assertTrue(result["proven"], result)
|
||||
self.assertTrue(result["has_author_lock"])
|
||||
self.assertEqual(result["matched_issue"], 727)
|
||||
self.assertEqual(result["matched_via"], "session_lock")
|
||||
self.assertIn(727, result["linked_issues"])
|
||||
|
||||
def test_issue_727_pr_728_durable_lock_accepted(self):
|
||||
lock = _live_lock(issue_number=727)
|
||||
path = issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=727,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
issue_lock_store.save_lock_file(path, lock)
|
||||
result = mcp._prove_author_ownership_for_pr(
|
||||
pr_number=728,
|
||||
pr_title="feat: pr sync",
|
||||
pr_body="Fixes #727",
|
||||
source_branch="feat/issue-727-pr-sync-status",
|
||||
remote="prgs",
|
||||
host=None,
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertTrue(result["proven"], result)
|
||||
self.assertEqual(result["matched_issue"], 727)
|
||||
self.assertEqual(result["matched_via"], "durable_lock")
|
||||
|
||||
def test_legacy_same_number_still_accepted(self):
|
||||
lock = _live_lock(
|
||||
issue_number=100,
|
||||
branch_name="fix/issue-100",
|
||||
worktree_path="/tmp/branches/issue-100",
|
||||
)
|
||||
issue_lock_store.bind_session_lock(lock)
|
||||
result = mcp._prove_author_ownership_for_pr(
|
||||
pr_number=100,
|
||||
pr_title="fix something",
|
||||
pr_body="Closes #100",
|
||||
source_branch="fix/issue-100",
|
||||
remote="prgs",
|
||||
host=None,
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path=lock["worktree_path"],
|
||||
)
|
||||
self.assertTrue(result["proven"], result)
|
||||
self.assertEqual(result["matched_issue"], 100)
|
||||
|
||||
def test_unrelated_issue_lock_rejected(self):
|
||||
"""Lock for issue #999 must not authorize PR #728 linked only to #727."""
|
||||
lock = _live_lock(
|
||||
issue_number=999,
|
||||
branch_name="feat/issue-999",
|
||||
worktree_path="/tmp/branches/issue-999",
|
||||
)
|
||||
issue_lock_store.bind_session_lock(lock)
|
||||
path = issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=999,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
issue_lock_store.save_lock_file(path, lock)
|
||||
result = mcp._prove_author_ownership_for_pr(
|
||||
pr_number=728,
|
||||
pr_title="feat: pr sync",
|
||||
pr_body="Closes #727",
|
||||
source_branch="feat/issue-727-pr-sync-status",
|
||||
remote="prgs",
|
||||
host=None,
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path=lock["worktree_path"],
|
||||
)
|
||||
self.assertFalse(result["proven"], result)
|
||||
self.assertFalse(result["has_author_lock"])
|
||||
self.assertIsNone(result["matched_issue"])
|
||||
self.assertTrue(result["reasons"])
|
||||
|
||||
def test_branch_mismatch_on_session_lock_fail_closed(self):
|
||||
lock = _live_lock(
|
||||
issue_number=727,
|
||||
branch_name="feat/other-branch",
|
||||
)
|
||||
issue_lock_store.bind_session_lock(lock)
|
||||
result = mcp._prove_author_ownership_for_pr(
|
||||
pr_number=728,
|
||||
pr_title="feat: pr sync",
|
||||
pr_body="Closes #727",
|
||||
source_branch="feat/issue-727-pr-sync-status",
|
||||
remote="prgs",
|
||||
host=None,
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path=lock["worktree_path"],
|
||||
)
|
||||
self.assertFalse(result["proven"], result)
|
||||
self.assertTrue(any("branch" in r for r in result["reasons"]))
|
||||
|
||||
def test_no_lock_fail_closed(self):
|
||||
result = mcp._prove_author_ownership_for_pr(
|
||||
pr_number=728,
|
||||
pr_title="feat: pr sync",
|
||||
pr_body="Closes #727",
|
||||
source_branch="feat/issue-727-pr-sync-status",
|
||||
remote="prgs",
|
||||
host=None,
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
self.assertFalse(result["proven"], result)
|
||||
self.assertTrue(any("no live author issue lock" in r for r in result["reasons"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Hermetic tests for PR synchronization / conflict-remediation lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from pr_sync_status import (
|
||||
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||
ACTION_BLOCKED,
|
||||
ACTION_FRESH_REVIEW_REQUIRED,
|
||||
ACTION_MERGE_NOW,
|
||||
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||
assess_post_update_head_transition,
|
||||
assess_pr_sync_status,
|
||||
assess_sequential_queue_step,
|
||||
assess_update_pr_branch_preflight,
|
||||
lease_usable_at_head,
|
||||
merge_approval_usable_at_head,
|
||||
)
|
||||
|
||||
|
||||
def _sha(prefix: str) -> str:
|
||||
return (prefix + "0" * 40)[:40]
|
||||
|
||||
|
||||
PR_HEAD = _sha("aaaaaaaa")
|
||||
BASE_HEAD = _sha("bbbbbbbb")
|
||||
NEW_HEAD = _sha("cccccccc")
|
||||
OLD_HEAD = _sha("dddddddd")
|
||||
|
||||
|
||||
def _base_kwargs(**overrides):
|
||||
data = {
|
||||
"host": "gitea.prgs.cc",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"pr_number": 100,
|
||||
"pr_state": "open",
|
||||
"source_branch": "fix/issue-100",
|
||||
"pr_head_sha": PR_HEAD,
|
||||
"base_head_sha": BASE_HEAD,
|
||||
"commits_behind": 0,
|
||||
"mergeable": True,
|
||||
"has_conflicts": False,
|
||||
"branch_protection_requires_current_base": False,
|
||||
"approval_at_current_head": True,
|
||||
"checks_status": "success",
|
||||
"active_author_lock": True,
|
||||
"active_reviewer_lease": False,
|
||||
"active_merger_lease": False,
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
class TestAssessPrSyncStatus(unittest.TestCase):
|
||||
def test_approved_current_mergeable_merge_now(self):
|
||||
result = assess_pr_sync_status(**_base_kwargs())
|
||||
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||
self.assertTrue(result["approval_valid_for_merge"])
|
||||
self.assertFalse(result["stale_approval"])
|
||||
self.assertEqual(result["pr_head_sha"], PR_HEAD)
|
||||
self.assertEqual(result["base_head_sha"], BASE_HEAD)
|
||||
|
||||
def test_approved_outdated_update_not_required_merge_now(self):
|
||||
result = assess_pr_sync_status(
|
||||
**_base_kwargs(
|
||||
commits_behind=3,
|
||||
branch_protection_requires_current_base=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||
self.assertTrue(result["approval_valid_for_merge"])
|
||||
self.assertTrue(any("does not require" in r for r in result["reasons"]))
|
||||
|
||||
def test_outdated_update_required_no_conflict(self):
|
||||
result = assess_pr_sync_status(
|
||||
**_base_kwargs(
|
||||
commits_behind=2,
|
||||
branch_protection_requires_current_base=True,
|
||||
mergeable=True,
|
||||
has_conflicts=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||
)
|
||||
self.assertFalse(result["approval_valid_for_merge"])
|
||||
self.assertTrue(any("update_branch_by_merge" in r for r in result["reasons"]))
|
||||
|
||||
def test_outdated_has_conflicts_author_remediation(self):
|
||||
result = assess_pr_sync_status(
|
||||
**_base_kwargs(
|
||||
commits_behind=5,
|
||||
branch_protection_requires_current_base=True,
|
||||
mergeable=False,
|
||||
has_conflicts=True,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
result["recommended_next_action"], ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||
)
|
||||
self.assertFalse(result["approval_valid_for_merge"])
|
||||
|
||||
def test_stale_approval_fresh_review_required(self):
|
||||
result = assess_pr_sync_status(
|
||||
**_base_kwargs(
|
||||
approval_at_current_head=False,
|
||||
commits_behind=0,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||
)
|
||||
self.assertTrue(result["stale_approval"])
|
||||
self.assertFalse(result["approval_valid_for_merge"])
|
||||
|
||||
def test_stale_prepared_verdict_cannot_cross_heads(self):
|
||||
result = assess_pr_sync_status(
|
||||
**_base_kwargs(
|
||||
approval_at_current_head=True,
|
||||
prepared_verdict_head_sha=OLD_HEAD,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||
)
|
||||
self.assertTrue(result["stale_prepared_verdict"])
|
||||
self.assertFalse(result["approval_valid_for_merge"])
|
||||
|
||||
def test_missing_heads_fail_closed(self):
|
||||
result = assess_pr_sync_status(
|
||||
**_base_kwargs(pr_head_sha="short", base_head_sha=None)
|
||||
)
|
||||
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||
self.assertTrue(any("PR head" in r for r in result["reasons"]))
|
||||
|
||||
def test_closed_pr_blocked(self):
|
||||
result = assess_pr_sync_status(**_base_kwargs(pr_state="closed"))
|
||||
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||
|
||||
def test_failed_checks_block_merge_now(self):
|
||||
result = assess_pr_sync_status(**_base_kwargs(checks_status="failure"))
|
||||
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||
|
||||
def test_stale_approval_with_update_required_routes_update(self):
|
||||
result = assess_pr_sync_status(
|
||||
**_base_kwargs(
|
||||
approval_at_current_head=False,
|
||||
commits_behind=1,
|
||||
branch_protection_requires_current_base=True,
|
||||
mergeable=True,
|
||||
has_conflicts=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||
)
|
||||
|
||||
|
||||
class TestUpdatePrBranchPreflight(unittest.TestCase):
|
||||
def _ok_kwargs(self, **overrides):
|
||||
data = {
|
||||
"role_kind": "author",
|
||||
"expected_pr_head_sha": PR_HEAD,
|
||||
"live_pr_head_sha": PR_HEAD,
|
||||
"expected_base_head_sha": BASE_HEAD,
|
||||
"live_base_head_sha": BASE_HEAD,
|
||||
"has_conflicts": False,
|
||||
"mergeable": True,
|
||||
"style": "merge",
|
||||
"has_author_lock": True,
|
||||
"worktree_path": "/Users/x/Development/Gitea-Tools/branches/issue-100",
|
||||
"worktree_owns_source_branch": True,
|
||||
"control_checkout_clean": True,
|
||||
"master_parity_ok": True,
|
||||
"force_push": False,
|
||||
"rebase": False,
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_author_allowed_when_preflight_clean(self):
|
||||
result = assess_update_pr_branch_preflight(**self._ok_kwargs())
|
||||
self.assertTrue(result["mutation_allowed"])
|
||||
self.assertFalse(result["head_race"])
|
||||
self.assertFalse(result["base_race"])
|
||||
|
||||
def test_head_race_fail_closed(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(live_pr_head_sha=NEW_HEAD)
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(result["head_race"])
|
||||
self.assertTrue(any("PR head race" in r for r in result["reasons"]))
|
||||
|
||||
def test_base_race_fail_closed(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(live_base_head_sha=NEW_HEAD)
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(result["base_race"])
|
||||
self.assertTrue(any("base head race" in r for r in result["reasons"]))
|
||||
|
||||
def test_conflicts_structured_handoff_no_mutation(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(has_conflicts=True, mergeable=False)
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(result["author_remediation_handoff"])
|
||||
self.assertTrue(any("conflicts" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_reviewer_denied(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(role_kind="reviewer")
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(any("author-only" in r for r in result["reasons"]))
|
||||
|
||||
def test_merger_denied(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(role_kind="merger")
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(any("author-only" in r for r in result["reasons"]))
|
||||
|
||||
def test_no_force_push(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(force_push=True)
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(any("force-push" in r for r in result["reasons"]))
|
||||
|
||||
def test_no_rebase(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(style="rebase", rebase=True)
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(any("rebase" in r for r in result["reasons"]))
|
||||
|
||||
def test_missing_lock_fail_closed(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(has_author_lock=False)
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
|
||||
def test_worktree_not_under_branches(self):
|
||||
result = assess_update_pr_branch_preflight(
|
||||
**self._ok_kwargs(worktree_path="/tmp/not-a-branches-path")
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(any("branches/" in r for r in result["reasons"]))
|
||||
|
||||
|
||||
class TestPostUpdateHeadTransition(unittest.TestCase):
|
||||
def test_update_invalidates_approval_and_requires_fresh_review(self):
|
||||
result = assess_post_update_head_transition(
|
||||
former_pr_head_sha=PR_HEAD,
|
||||
new_pr_head_sha=NEW_HEAD,
|
||||
former_approval_head_sha=PR_HEAD,
|
||||
former_reviewer_lease_head_sha=PR_HEAD,
|
||||
former_merger_lease_head_sha=PR_HEAD,
|
||||
prepared_verdict_head_sha=PR_HEAD,
|
||||
)
|
||||
self.assertTrue(result["head_changed"])
|
||||
self.assertTrue(result["approval_invalidated"])
|
||||
self.assertTrue(result["reviewer_lease_superseded"])
|
||||
self.assertTrue(result["merger_lease_superseded"])
|
||||
self.assertTrue(result["prepared_verdict_invalidated"])
|
||||
self.assertEqual(
|
||||
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||
)
|
||||
|
||||
def test_same_head_unexpected(self):
|
||||
result = assess_post_update_head_transition(
|
||||
former_pr_head_sha=PR_HEAD,
|
||||
new_pr_head_sha=PR_HEAD,
|
||||
)
|
||||
self.assertFalse(result["head_changed"])
|
||||
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||
|
||||
|
||||
class TestStaleArtifacts(unittest.TestCase):
|
||||
def test_stale_approval_cannot_authorize_merge(self):
|
||||
result = merge_approval_usable_at_head(
|
||||
current_head_sha=NEW_HEAD,
|
||||
approved_head_sha=PR_HEAD,
|
||||
)
|
||||
self.assertFalse(result["approval_usable"])
|
||||
|
||||
def test_fresh_approval_usable(self):
|
||||
result = merge_approval_usable_at_head(
|
||||
current_head_sha=NEW_HEAD,
|
||||
approved_head_sha=NEW_HEAD,
|
||||
)
|
||||
self.assertTrue(result["approval_usable"])
|
||||
|
||||
def test_stale_reviewer_lease_cannot_cross_heads(self):
|
||||
result = lease_usable_at_head(
|
||||
lease_kind="reviewer",
|
||||
lease_head_sha=PR_HEAD,
|
||||
current_head_sha=NEW_HEAD,
|
||||
)
|
||||
self.assertFalse(result["lease_usable"])
|
||||
|
||||
def test_stale_merger_lease_cannot_cross_heads(self):
|
||||
result = lease_usable_at_head(
|
||||
lease_kind="merger",
|
||||
lease_head_sha=PR_HEAD,
|
||||
current_head_sha=NEW_HEAD,
|
||||
)
|
||||
self.assertFalse(result["lease_usable"])
|
||||
|
||||
|
||||
class TestSequentialQueue(unittest.TestCase):
|
||||
def test_reassess_after_merge_and_master_refresh(self):
|
||||
result = assess_sequential_queue_step(
|
||||
just_merged=True,
|
||||
master_refreshed=True,
|
||||
next_pr_number=101,
|
||||
)
|
||||
self.assertTrue(result["reassess_allowed"])
|
||||
self.assertTrue(result["process_next"])
|
||||
self.assertTrue(result["post_merge_cleanup_handoff"])
|
||||
self.assertEqual(result["next_pr_number"], 101)
|
||||
|
||||
def test_block_reassess_without_master_refresh(self):
|
||||
result = assess_sequential_queue_step(
|
||||
just_merged=True,
|
||||
master_refreshed=False,
|
||||
next_pr_number=101,
|
||||
)
|
||||
self.assertFalse(result["reassess_allowed"])
|
||||
self.assertTrue(any("master must be refreshed" in r for r in result["reasons"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -82,6 +82,42 @@ class TestReconcilerProfileModel(unittest.TestCase):
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_branch_delete_is_recommended_for_reconciler(self):
|
||||
self.assertIn(
|
||||
"gitea.branch.delete",
|
||||
reconciler_profile.RECONCILER_RECOMMENDED_OPERATIONS,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete",
|
||||
reconciler_profile.RECONCILER_REQUIRED_OPERATIONS,
|
||||
)
|
||||
|
||||
def test_reconciler_with_branch_delete_stays_valid(self):
|
||||
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.branch.delete"]
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
allowed,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["is_reconciler_profile"])
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertNotIn(
|
||||
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||
)
|
||||
self.assertEqual(
|
||||
mcp_server._role_kind(allowed, PRGS_RECONCILER_FORBIDDEN),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_reconciler_without_branch_delete_reports_missing_recommended(self):
|
||||
result = reconciler_profile.assess_reconciler_profile(
|
||||
PRGS_RECONCILER_ALLOWED,
|
||||
PRGS_RECONCILER_FORBIDDEN,
|
||||
)
|
||||
self.assertTrue(result["valid"])
|
||||
self.assertIn(
|
||||
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -231,9 +231,16 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
|
||||
|
||||
def test_resolve_unknown_task_fails_closed(self):
|
||||
# #723: unknown tasks return structured unknown_task (no ValueError escape).
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server.gitea_resolve_task_capability(task="invalid_task_xyz", remote="prgs")
|
||||
res = mcp_server.gitea_resolve_task_capability(
|
||||
task="invalid_task_xyz", remote="prgs"
|
||||
)
|
||||
self.assertIsInstance(res, dict)
|
||||
self.assertEqual(res.get("reason_code"), "unknown_task", res)
|
||||
self.assertFalse(res.get("allowed_in_current_session"))
|
||||
self.assertTrue(res.get("stop_required"))
|
||||
self.assertIs(res.get("mutation_performed"), False)
|
||||
|
||||
# Additional regression tests per #145 for permission boundaries and structured guidance
|
||||
def test_issue_comment_does_not_imply_close(self):
|
||||
@@ -439,8 +446,11 @@ class TestResolveTaskCapability(unittest.TestCase):
|
||||
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
||||
for unknown in ("close_pull_request", "close", "pr_close"):
|
||||
with self.assertRaises(ValueError):
|
||||
mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs")
|
||||
unk = mcp_server.gitea_resolve_task_capability(
|
||||
task=unknown, remote="prgs"
|
||||
)
|
||||
self.assertEqual(unk.get("reason_code"), "unknown_task", unk)
|
||||
self.assertFalse(unk.get("allowed_in_current_session"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -122,9 +122,11 @@ class TestApiRequestRetry(unittest.TestCase):
|
||||
sleep.assert_not_called()
|
||||
|
||||
def test_non_429_error_raises_immediately(self):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
_call([_http_error(500, body=b"boom")])
|
||||
self.assertIn("HTTP 500", str(ctx.exception))
|
||||
# Fixed message only (#699) — no response body echo.
|
||||
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||
self.assertEqual(ctx.exception.http_status, 500)
|
||||
|
||||
def test_non_429_error_does_not_sleep(self):
|
||||
sleep = MagicMock()
|
||||
@@ -171,9 +173,12 @@ class TestApiRequestRetry(unittest.TestCase):
|
||||
# max_retries=3 -> 3 sleeps, then the 4th failure raises.
|
||||
errors = [_http_error(429, retry_after="1") for _ in range(4)]
|
||||
sleep = MagicMock()
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
_call(errors, sleep_func=sleep, max_retries=3)
|
||||
self.assertIn("HTTP 429", str(ctx.exception))
|
||||
# After retries are exhausted, 429 is a typed HTTP error with fixed
|
||||
# message (#699); status metadata carries 429.
|
||||
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||
self.assertEqual(ctx.exception.http_status, 429)
|
||||
self.assertEqual(sleep.call_count, 3)
|
||||
|
||||
def test_no_infinite_loop_when_always_429(self):
|
||||
|
||||
+101
-2
@@ -68,8 +68,6 @@ class TestReviewDrafts(unittest.TestCase):
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_save_draft_success(self, mock_api):
|
||||
print("GITEA_MCP_SERVER FILE:", gitea_mcp_server.__file__)
|
||||
print("DIR OF GITEA_MCP_SERVER:", dir(gitea_mcp_server))
|
||||
mock_api.return_value = {
|
||||
"head": {"sha": "headsha1234567890"},
|
||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||
@@ -285,3 +283,104 @@ class TestReviewDrafts(unittest.TestCase):
|
||||
)
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||
def test_resume_draft_foreign_lease(self, mock_comments, mock_api):
|
||||
mock_api.return_value = {
|
||||
"state": "open",
|
||||
"head": {"sha": "headsha1234567890"},
|
||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||
}
|
||||
gitea_mcp_server.gitea_save_review_draft(
|
||||
pr_number=587,
|
||||
action="approve",
|
||||
body="Good changes",
|
||||
expected_head_sha="headsha1234567890",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/test-worktree",
|
||||
)
|
||||
reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": 587,
|
||||
"session_id": "my-session",
|
||||
})
|
||||
mock_comments.return_value = [
|
||||
{
|
||||
"id": 9,
|
||||
"user": {"username": "sysadmin"},
|
||||
"body": (
|
||||
"<!-- mcp-review-lease:v1 -->\n"
|
||||
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
||||
"pr: #587\n"
|
||||
"reviewer_identity: sysadmin\n"
|
||||
"profile: prgs-reviewer\n"
|
||||
"session_id: foreign-session-999\n"
|
||||
"phase: claimed\n"
|
||||
),
|
||||
}
|
||||
]
|
||||
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||
pr_number=587,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/test-worktree",
|
||||
)
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertTrue(
|
||||
any("foreign-session-999" in r or "session_id" in r for r in (res.get("reasons") or []))
|
||||
)
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||
@patch("gitea_mcp_server.terminal_review_hard_stop_reasons")
|
||||
def test_resume_draft_terminal_lock_blocks(self, mock_hard_stop, mock_comments, mock_api):
|
||||
mock_api.return_value = {
|
||||
"state": "open",
|
||||
"head": {"sha": "headsha1234567890"},
|
||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||
}
|
||||
gitea_mcp_server.gitea_save_review_draft(
|
||||
pr_number=587,
|
||||
action="approve",
|
||||
body="Good changes",
|
||||
expected_head_sha="headsha1234567890",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/test-worktree",
|
||||
)
|
||||
reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": 587,
|
||||
"session_id": "session-1234",
|
||||
})
|
||||
mock_comments.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "sysadmin"},
|
||||
"body": (
|
||||
"<!-- mcp-review-lease:v1 -->\n"
|
||||
"repo: Scaled-Tech-Consulting/Gitea-Tools\n"
|
||||
"pr: #587\n"
|
||||
"reviewer_identity: sysadmin\n"
|
||||
"profile: prgs-reviewer\n"
|
||||
"session_id: session-1234\n"
|
||||
"phase: claimed\n"
|
||||
),
|
||||
}
|
||||
]
|
||||
mock_hard_stop.return_value = [
|
||||
"terminal review mutation already recorded for PR #587 (approve); #332 hard-stop"
|
||||
]
|
||||
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||
pr_number=587,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/test-worktree",
|
||||
)
|
||||
self.assertFalse(res.get("success"))
|
||||
self.assertTrue(any("#332" in r or "terminal" in r for r in (res.get("reasons") or [])))
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Tests for optional self-hosted Sentry observability (#606).
|
||||
|
||||
Covers the pure module (config, redaction, event/check-in builders) and the
|
||||
runtime capture paths using a fake ``sentry_sdk``, so nothing ever touches the
|
||||
network. Critically proves the feature is a no-op when disabled or DSN-less
|
||||
(acceptance criterion 1) and that secrets/paths/session-state are never sent
|
||||
(criterion 5).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import sentry_observability as so # noqa: E402
|
||||
|
||||
|
||||
# ── Fake SDK ────────────────────────────────────────────────────────────────
|
||||
class _FakeScope:
|
||||
def __init__(self):
|
||||
self.tags = {}
|
||||
self.extras = {}
|
||||
|
||||
def set_tag(self, k, v):
|
||||
self.tags[k] = v
|
||||
|
||||
def set_extra(self, k, v):
|
||||
self.extras[k] = v
|
||||
|
||||
|
||||
class FakeSentrySDK:
|
||||
"""Minimal stand-in exposing the SDK surface sentry_observability uses."""
|
||||
|
||||
def __init__(self):
|
||||
self.init_kwargs = None
|
||||
self.events = []
|
||||
self.exceptions = []
|
||||
self.checkins = []
|
||||
self.last_scope = None
|
||||
|
||||
def init(self, **kwargs):
|
||||
self.init_kwargs = kwargs
|
||||
|
||||
def capture_event(self, event):
|
||||
self.events.append(event)
|
||||
|
||||
def capture_exception(self, exc):
|
||||
self.exceptions.append(exc)
|
||||
|
||||
def capture_checkin(self, **payload):
|
||||
self.checkins.append(payload)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def push_scope(self):
|
||||
self.last_scope = _FakeScope()
|
||||
yield self.last_scope
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
"""Isolate module init state between tests."""
|
||||
so.reset_for_tests()
|
||||
yield
|
||||
so.reset_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_sdk(monkeypatch):
|
||||
sdk = FakeSentrySDK()
|
||||
monkeypatch.setattr(so, "sentry_sdk", sdk)
|
||||
return sdk
|
||||
|
||||
|
||||
# ── Config / gating ─────────────────────────────────────────────────────────
|
||||
def test_disabled_by_default_empty_env():
|
||||
cfg = so.load_config(env={})
|
||||
assert cfg.enabled is False
|
||||
assert cfg.active is False
|
||||
|
||||
|
||||
def test_enabled_flag_without_dsn_is_not_active():
|
||||
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": "1"})
|
||||
assert cfg.enabled is True
|
||||
assert cfg.dsn is None
|
||||
assert cfg.active is False # DSN required
|
||||
|
||||
|
||||
def test_dsn_without_enabled_flag_is_not_active():
|
||||
cfg = so.load_config(env={"SENTRY_DSN": "https://[email protected]/1"})
|
||||
assert cfg.active is False
|
||||
|
||||
|
||||
def test_active_requires_enabled_and_dsn():
|
||||
cfg = so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "true", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
assert cfg.active is True
|
||||
|
||||
|
||||
def test_truthy_variants():
|
||||
for val in ("1", "true", "YES", "On"):
|
||||
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
||||
assert cfg.enabled is True
|
||||
for val in ("0", "false", "no", "", "off"):
|
||||
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
||||
assert cfg.enabled is False
|
||||
|
||||
|
||||
def test_traces_sample_rate_parsed_and_clamped():
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.25"}).traces_sample_rate == 0.25
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "5"}).traces_sample_rate == 1.0
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "-1"}).traces_sample_rate == 0.0
|
||||
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "junk"}).traces_sample_rate == 0.0
|
||||
|
||||
|
||||
def test_safe_summary_has_no_dsn_value():
|
||||
cfg = so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
summary = cfg.safe_summary()
|
||||
assert summary["dsn_present"] is True
|
||||
assert "secret" not in repr(summary)
|
||||
assert "dsn" not in summary # only presence, never the value
|
||||
|
||||
|
||||
# ── init_sentry ─────────────────────────────────────────────────────────────
|
||||
def test_init_noop_when_disabled(fake_sdk):
|
||||
status = so.init_sentry(so.load_config(env={}))
|
||||
assert status["initialized"] is False
|
||||
assert fake_sdk.init_kwargs is None # no SDK init
|
||||
assert so.is_initialized() is False
|
||||
|
||||
|
||||
def test_init_noop_when_enabled_but_missing_dsn(fake_sdk):
|
||||
status = so.init_sentry(so.load_config(env={"MCP_SENTRY_ENABLED": "1"}))
|
||||
assert status["initialized"] is False
|
||||
assert fake_sdk.init_kwargs is None
|
||||
|
||||
|
||||
def test_init_reports_missing_sdk(monkeypatch):
|
||||
monkeypatch.setattr(so, "sentry_sdk", None)
|
||||
status = so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
assert status["initialized"] is False
|
||||
assert "not installed" in status["reason"]
|
||||
|
||||
|
||||
def test_init_configures_sdk_with_scrubber(fake_sdk):
|
||||
status = so.init_sentry(
|
||||
so.load_config(
|
||||
env={
|
||||
"MCP_SENTRY_ENABLED": "1",
|
||||
"SENTRY_DSN": "https://[email protected]/1",
|
||||
"SENTRY_ENVIRONMENT": "prod",
|
||||
"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.1",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert status["initialized"] is True
|
||||
assert so.is_initialized() is True
|
||||
kw = fake_sdk.init_kwargs
|
||||
assert kw["dsn"] == "https://[email protected]/1"
|
||||
assert kw["environment"] == "prod"
|
||||
assert kw["traces_sample_rate"] == 0.1
|
||||
assert kw["before_send"] is so.scrub_event
|
||||
assert kw["send_default_pii"] is False
|
||||
|
||||
|
||||
def test_init_enable_logs_wires_log_scrubber(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={
|
||||
"MCP_SENTRY_ENABLED": "1",
|
||||
"SENTRY_DSN": "https://[email protected]/1",
|
||||
"MCP_SENTRY_ENABLE_LOGS": "1",
|
||||
}
|
||||
)
|
||||
)
|
||||
exp = fake_sdk.init_kwargs["_experiments"]
|
||||
assert exp["enable_logs"] is True
|
||||
assert exp["before_send_log"] is so.scrub_event
|
||||
|
||||
|
||||
def test_init_never_raises_on_sdk_failure(monkeypatch):
|
||||
class Boom:
|
||||
def init(self, **kwargs):
|
||||
raise RuntimeError("sentry down")
|
||||
|
||||
monkeypatch.setattr(so, "sentry_sdk", Boom())
|
||||
status = so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
assert status["initialized"] is False
|
||||
assert "init failed" in status["reason"]
|
||||
|
||||
|
||||
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
||||
def test_build_tags_allowlist_only():
|
||||
tags = so.build_tags(role="author", secret_thing="leak", pid=123)
|
||||
assert tags["role"] == "author"
|
||||
assert tags["pid"] == "123"
|
||||
assert "secret_thing" not in tags
|
||||
|
||||
|
||||
def test_build_tags_hashes_session_id():
|
||||
tags = so.build_tags(session_id="prgs-author-20479-cf9ac178")
|
||||
assert "session_id" not in tags
|
||||
assert "session_id_hash" in tags
|
||||
assert tags["session_id_hash"] != "prgs-author-20479-cf9ac178"
|
||||
assert len(tags["session_id_hash"]) == 12
|
||||
|
||||
|
||||
def test_build_tags_collapses_worktree_path():
|
||||
tags = so.build_tags(
|
||||
worktree_path="/Users/x/Development/Gitea-Tools/branches/issue-606-sentry-observability"
|
||||
)
|
||||
assert "worktree_path" not in tags
|
||||
assert tags["worktree_category"] == "author"
|
||||
|
||||
|
||||
def test_build_tags_drops_value_that_scrubs_to_redacted():
|
||||
# A tag value that is itself a token gets scrubbed then dropped.
|
||||
tags = so.build_tags(capability="token abcdef1234567890")
|
||||
assert "capability" not in tags
|
||||
|
||||
|
||||
def test_sanitize_path_categories():
|
||||
assert so.sanitize_path("/repo/branches/review-pr-654") == "reviewer"
|
||||
assert so.sanitize_path("/repo/branches/merge-pr-1") == "merger"
|
||||
assert so.sanitize_path("/repo/branches/reconcile-pr-1") == "reconciler"
|
||||
assert so.sanitize_path("/repo/branches/feat-issue-606") == "author"
|
||||
assert so.sanitize_path("/x/y/Gitea-Tools") == "root"
|
||||
|
||||
|
||||
def test_redact_value_scrubs_secrets_and_paths():
|
||||
out = so.redact_value(
|
||||
{
|
||||
"token": "abc123",
|
||||
"note": "Authorization: Bearer sk_live_abcdefgh12345",
|
||||
"path": "/Users/jasonwalker/Development/Gitea-Tools/secret",
|
||||
"dsn": "https://[email protected]/1",
|
||||
"safe": "hello",
|
||||
}
|
||||
)
|
||||
assert out["token"] == so.REDACTED
|
||||
assert out["dsn"] == so.REDACTED
|
||||
assert "sk_live" not in out["note"]
|
||||
assert so.REDACTED_PATH in out["path"]
|
||||
assert "/Users/" not in out["path"]
|
||||
assert out["safe"] == "hello"
|
||||
|
||||
|
||||
def test_redact_value_forbidden_prompt_and_session_state():
|
||||
out = so.redact_value(
|
||||
{"prompt": "full body", "session_state": "{...}", "keep": "ok"}
|
||||
)
|
||||
assert out["prompt"] == so.REDACTED
|
||||
assert out["session_state"] == so.REDACTED
|
||||
assert out["keep"] == "ok"
|
||||
|
||||
|
||||
def test_scrub_event_redacts_nested_and_drops_server_name():
|
||||
event = {
|
||||
"server_name": "some-host",
|
||||
"message": "boom",
|
||||
"extra": {"token": "leak", "ok": "1"},
|
||||
}
|
||||
scrubbed = so.scrub_event(event)
|
||||
assert "server_name" not in scrubbed
|
||||
assert scrubbed["extra"]["token"] == so.REDACTED
|
||||
assert scrubbed["extra"]["ok"] == "1"
|
||||
|
||||
|
||||
def test_scrub_event_drops_non_dict():
|
||||
assert so.scrub_event("not a dict") is None
|
||||
assert so.scrub_event(None) is None
|
||||
|
||||
|
||||
# ── Event builders ──────────────────────────────────────────────────────────
|
||||
def test_build_blocker_event_structure_and_next_action():
|
||||
event = so.build_blocker_event(
|
||||
"active_foreign_lease",
|
||||
message="blocked by foreign lease",
|
||||
next_action="wait or adopt via allocator",
|
||||
level="warning",
|
||||
tags={"pr_number": 606, "session_id": "s-123"},
|
||||
)
|
||||
assert event["tags"]["blocker_type"] == "active_foreign_lease"
|
||||
assert event["tags"]["pr_number"] == "606"
|
||||
assert "session_id" not in event["tags"]
|
||||
assert event["tags"]["session_id_hash"]
|
||||
assert event["extra"]["canonical_next_action"] == "wait or adopt via allocator"
|
||||
assert event["fingerprint"] == ["workflow-blocker", "active_foreign_lease"]
|
||||
|
||||
|
||||
def test_build_blocker_event_invalid_level_defaults_warning():
|
||||
event = so.build_blocker_event("x", level="nonsense")
|
||||
assert event["level"] == "warning"
|
||||
|
||||
|
||||
def test_build_checkin_payload_maps_slugs():
|
||||
for key, slug in so.MONITOR_SLUGS.items():
|
||||
payload = so.build_checkin_payload(key, "ok")
|
||||
assert payload["monitor_slug"] == slug
|
||||
assert payload["status"] == "ok"
|
||||
|
||||
|
||||
def test_build_checkin_payload_explicit_slug_passthrough():
|
||||
payload = so.build_checkin_payload("custom-slug", "in_progress", duration=1.5)
|
||||
assert payload["monitor_slug"] == "custom-slug"
|
||||
assert payload["duration"] == 1.5
|
||||
|
||||
|
||||
def test_build_checkin_payload_rejects_bad_status():
|
||||
with pytest.raises(ValueError):
|
||||
so.build_checkin_payload("allocator_health", "bogus")
|
||||
|
||||
|
||||
def test_all_six_monitors_registered():
|
||||
assert set(so.MONITOR_SLUGS) == {
|
||||
"stale_lease_scan",
|
||||
"terminal_lock_scan",
|
||||
"allocator_health",
|
||||
"namespace_health",
|
||||
"dashboard_freshness",
|
||||
"reconciler_cleanup",
|
||||
}
|
||||
|
||||
|
||||
# ── Capture paths (fail open) ───────────────────────────────────────────────
|
||||
def test_capture_workflow_blocker_noop_when_disabled(fake_sdk):
|
||||
# not initialised
|
||||
event = so.capture_workflow_blocker("some_blocker", message="x")
|
||||
assert isinstance(event, dict) # still returns redacted event
|
||||
assert fake_sdk.events == [] # but nothing sent
|
||||
|
||||
|
||||
def test_capture_workflow_blocker_sends_when_initialised(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
so.capture_workflow_blocker("terminal_lock_occupied", message="held")
|
||||
assert len(fake_sdk.events) == 1
|
||||
assert fake_sdk.events[0]["tags"]["blocker_type"] == "terminal_lock_occupied"
|
||||
|
||||
|
||||
def test_capture_exception_noop_when_disabled(fake_sdk):
|
||||
assert so.capture_exception(ValueError("x")) is False
|
||||
assert fake_sdk.exceptions == []
|
||||
|
||||
|
||||
def test_capture_exception_sends_scrubbed_tags(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
ok = so.capture_exception(
|
||||
RuntimeError("bad"), tags={"mutation_tool": "gitea_merge_pr", "leaky": "x"}
|
||||
)
|
||||
assert ok is True
|
||||
assert len(fake_sdk.exceptions) == 1
|
||||
assert fake_sdk.last_scope.tags["mutation_tool"] == "gitea_merge_pr"
|
||||
assert "leaky" not in fake_sdk.last_scope.tags
|
||||
|
||||
|
||||
def test_capture_exception_never_raises(monkeypatch, fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
|
||||
def boom(exc):
|
||||
raise RuntimeError("sdk exploded")
|
||||
|
||||
monkeypatch.setattr(fake_sdk, "capture_exception", boom)
|
||||
# Must swallow the SDK failure (fail open).
|
||||
assert so.capture_exception(ValueError("y")) is False
|
||||
|
||||
|
||||
def test_monitor_checkin_noop_when_disabled(fake_sdk):
|
||||
payload = so.monitor_checkin("allocator_health", "ok")
|
||||
assert payload["monitor_slug"] == "gitea-mcp-allocator-health"
|
||||
assert fake_sdk.checkins == [] # not sent while disabled
|
||||
|
||||
|
||||
def test_monitor_checkin_sends_when_initialised(fake_sdk):
|
||||
so.init_sentry(
|
||||
so.load_config(
|
||||
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||
)
|
||||
)
|
||||
so.monitor_checkin("stale_lease_scan", "ok")
|
||||
assert fake_sdk.checkins == [
|
||||
{"monitor_slug": "gitea-mcp-stale-lease-scan", "status": "ok"}
|
||||
]
|
||||
|
||||
|
||||
def test_monitor_checkin_invalid_status_returns_none(fake_sdk):
|
||||
assert so.monitor_checkin("allocator_health", "bogus") is None
|
||||
assert fake_sdk.checkins == []
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
|
||||
|
||||
Enforces review 443 remediation:
|
||||
|
||||
* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2).
|
||||
* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process
|
||||
restart is operator-owned; ADR is the authoritative split.
|
||||
* F3 — routine post-merge master-parity staleness has a sanctioned response
|
||||
(stop → report → operator reload → re-verify parity) and is not a full
|
||||
promotion ledger requirement.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ADR = (
|
||||
REPO_ROOT
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "mcp-stable-control-runtime-policy-adr.md"
|
||||
)
|
||||
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
|
||||
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
|
||||
|
||||
CROSS_LINK_DOCS = (
|
||||
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
|
||||
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
|
||||
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
|
||||
)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_adr_exists_with_policy_core():
|
||||
text = _read(ADR)
|
||||
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||
assert "#615" in text
|
||||
assert "stable control runtime" in text.lower()
|
||||
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
|
||||
|
||||
|
||||
def test_f1_operator_docs_cross_link_adr():
|
||||
for path in CROSS_LINK_DOCS:
|
||||
text = _read(path)
|
||||
assert ADR_BASENAME in text, (
|
||||
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
||||
f"(issue #615 acceptance criterion 2)"
|
||||
)
|
||||
|
||||
|
||||
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
|
||||
text = _read(ADR)
|
||||
# Acceptance section must require guide/runbook cross-links.
|
||||
assert "Operator guide / runbooks cross-link" in text or (
|
||||
"operator guide" in text.lower() and "cross-link" in text.lower()
|
||||
and "Acceptance" in text
|
||||
)
|
||||
# Cross-link must not remain only as optional tooling item #4.
|
||||
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
|
||||
"## 6. Acceptance", 1
|
||||
)[0]
|
||||
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
|
||||
"ADR §5 must not list operator-guide cross-link as optional tooling"
|
||||
)
|
||||
assert "Not optional" in text or "must** cross-link" in text.lower() or (
|
||||
"must cross-link" in text.lower()
|
||||
)
|
||||
|
||||
|
||||
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
|
||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||
# Forbidden historical self-service instruction.
|
||||
forbidden = (
|
||||
"the LLM must relaunch or restart the client/MCP with the correct "
|
||||
"profile environment variable before claiming or working on any tasks"
|
||||
)
|
||||
assert forbidden not in runbooks, (
|
||||
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
|
||||
)
|
||||
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
|
||||
assert ADR_BASENAME in runbooks
|
||||
|
||||
|
||||
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
|
||||
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
|
||||
section = section.split("## Safety notes", 1)[0]
|
||||
# Must not tell the LLM alone to relaunch the MCP process as step 2.
|
||||
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
|
||||
assert "Operator-owned" in section or "operator" in section.lower()
|
||||
assert "worktree_path" in section
|
||||
collapsed = " ".join(section.lower().split())
|
||||
assert "client reconnect" in collapsed
|
||||
|
||||
|
||||
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "must not" in lower and "restart" in lower
|
||||
assert "operator" in lower and "reload" in lower
|
||||
assert "supersedes" in lower
|
||||
assert "llm" in lower
|
||||
|
||||
|
||||
def test_f3_adr_defines_post_merge_parity_staleness_response():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "2.6" in text
|
||||
assert "post-merge" in lower or "post merge" in lower
|
||||
assert "parity" in lower and "stale" in lower
|
||||
# Sanctioned steps: stop, report, operator reload, re-verify.
|
||||
assert "stop" in lower
|
||||
assert "report" in lower
|
||||
assert "operator" in lower
|
||||
assert "parity is verified" in lower or "re-verif" in lower or (
|
||||
"startup/current-head" in lower
|
||||
)
|
||||
# Not a full promotion ledger for routine reload.
|
||||
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
|
||||
"not a §2.4" in text or "Not a §2.4 promotion" in text
|
||||
)
|
||||
# Stale parity listed among unhealthy triggers.
|
||||
assert "master parity is stale" in lower or "stale master parity" in lower
|
||||
|
||||
|
||||
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
|
||||
text = _read(ADR)
|
||||
lower = text.lower()
|
||||
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
|
||||
assert "parity" in lower
|
||||
|
||||
|
||||
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
|
||||
for path in CROSS_LINK_DOCS:
|
||||
text = _read(path)
|
||||
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||
assert marker not in text, f"{path} contains {marker!r}"
|
||||
@@ -184,20 +184,32 @@ class TestActiveHardStopPreserved(unittest.TestCase):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_open_approve_still_blocks_other_pr_mark_ready(self):
|
||||
_seed([APPROVED_OPEN])
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready")
|
||||
self.assertTrue(reasons)
|
||||
self.assertIn("#332", reasons[0])
|
||||
self.assertIn("gitea_cleanup_stale_review_decision_lock", reasons[0])
|
||||
def test_open_approve_blocks_same_pr_not_other_pr_mark_ready(self):
|
||||
_seed([APPROVED_OPEN]) # approve on PR #700
|
||||
# Same PR still hard-stopped for mark_ready.
|
||||
reasons_same = mcp_server.terminal_review_hard_stop_reasons(
|
||||
700, "mark_ready"
|
||||
)
|
||||
self.assertTrue(reasons_same)
|
||||
self.assertIn("#332", reasons_same[0])
|
||||
# #693: other PR may mark_ready without cleanup.
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_request_changes_still_blocks_everything(self):
|
||||
_seed([RC_OPEN])
|
||||
def test_request_changes_still_blocks_same_pr(self):
|
||||
_seed([RC_OPEN]) # request_changes on PR #701
|
||||
for op in ("merge", "mark_ready", "review"):
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, op),
|
||||
mcp_server.terminal_review_hard_stop_reasons(701, op),
|
||||
msg=op,
|
||||
)
|
||||
# Foreign PR review path is open (#693).
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Structured MCP auth errors and stdio transport survival (#699 / PR #701).
|
||||
|
||||
Covers original AC plus reviewer-ratified regressions:
|
||||
|
||||
1. Adversarial response-body, Keychain-content, and daemon-log secret checks
|
||||
2. Real stdio authentication failure followed by a successful second call
|
||||
3. UrlElicitationRequiredError framework re-raise behavior
|
||||
4. Unexpected parser RuntimeError is not authentication
|
||||
5. Generic HTTP 403 is authorization (not internal)
|
||||
6. Repeated install/import is idempotent
|
||||
7. Author and reconciler profiles
|
||||
8. Native provenance non-bypass
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
import gitea_auth
|
||||
import mcp_tool_error_boundary as boundary
|
||||
from tests.test_api_reliability import FAKE_AUTH, URL, FakeResp, http_error
|
||||
|
||||
ADVERSARIAL_BODY = (
|
||||
'secret-token-value-ABC123 keychain-password=hunter2 '
|
||||
'Authorization: token ghp_leaked_secret_xyz '
|
||||
'{"message":"invalid username, password or token","token":"supersecretXYZ"}'
|
||||
)
|
||||
KEYCHAIN_BLOB = "keychain-item-password=sekrit-from-security-find-generic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_request / HTTP classification
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestHttpClassification(unittest.TestCase):
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_401_typed_auth_fixed_message_no_body(self, mock_open):
|
||||
mock_open.side_effect = http_error(401, ADVERSARIAL_BODY)
|
||||
with self.assertRaises(gitea_auth.GiteaAuthError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
exc = ctx.exception
|
||||
self.assertEqual(exc.reason_code, "auth_invalid_token")
|
||||
self.assertEqual(exc.error_class, "authentication")
|
||||
self.assertEqual(exc.http_status, 401)
|
||||
msg = str(exc)
|
||||
self.assertNotIn("supersecretXYZ", msg)
|
||||
self.assertNotIn("ghp_leaked", msg)
|
||||
self.assertNotIn("hunter2", msg)
|
||||
self.assertNotIn(ADVERSARIAL_BODY, msg)
|
||||
self.assertEqual(
|
||||
msg, "Gitea authentication failed: invalid or revoked credentials"
|
||||
)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_403_scope_is_authz_scope(self, mock_open):
|
||||
mock_open.side_effect = http_error(
|
||||
403,
|
||||
'{"message":"token does not have at least one of required scope(s)"}',
|
||||
)
|
||||
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(ctx.exception.reason_code, "authz_insufficient_scope")
|
||||
self.assertEqual(ctx.exception.error_class, "authorization")
|
||||
self.assertNotIn("token does not have", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_generic_403_is_authz_not_internal(self, mock_open):
|
||||
mock_open.side_effect = http_error(403, '{"message":"user has no permission"}')
|
||||
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(ctx.exception.reason_code, "authz_denied")
|
||||
self.assertEqual(ctx.exception.error_class, "authorization")
|
||||
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
|
||||
self.assertNotIn("user has no permission", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_network_fixed_message(self, mock_open):
|
||||
mock_open.side_effect = TimeoutError("timed out contacting secret.example")
|
||||
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||
self.assertNotIn("secret.example", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_json_not_auth(self, mock_open):
|
||||
mock_open.return_value = FakeResp("not-json{")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
|
||||
self.assertIn("malformed JSON", str(ctx.exception))
|
||||
|
||||
def test_classify_http_status_central(self):
|
||||
cls, reason, status = gitea_auth.classify_http_status(401)
|
||||
self.assertIs(cls, gitea_auth.GiteaAuthError)
|
||||
self.assertEqual(reason, "auth_invalid_token")
|
||||
self.assertEqual(status, 401)
|
||||
|
||||
cls, reason, status = gitea_auth.classify_http_status(403)
|
||||
self.assertIs(cls, gitea_auth.GiteaAuthzError)
|
||||
self.assertEqual(reason, "authz_denied")
|
||||
|
||||
cls, reason, status = gitea_auth.classify_http_status(
|
||||
403, body_hint="missing scope write:issue"
|
||||
)
|
||||
self.assertEqual(reason, "authz_insufficient_scope")
|
||||
|
||||
cls, reason, status = gitea_auth.classify_http_status(502)
|
||||
self.assertIs(cls, gitea_auth.GiteaHttpError)
|
||||
self.assertEqual(reason, "upstream_unavailable")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary classification — no heuristics, no secret leakage
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestBoundaryClassification(unittest.TestCase):
|
||||
def test_auth_payload_fixed_message(self):
|
||||
exc = gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
|
||||
# Even if someone mutates __str__ path, classification uses fixed text.
|
||||
result = boundary.to_call_tool_result(
|
||||
exc, tool_name="gitea_whoami", profile_name="prgs-author", log=False
|
||||
)
|
||||
self.assertTrue(result.isError)
|
||||
p = result.structuredContent
|
||||
self.assertEqual(p["reason_code"], "auth_invalid_token")
|
||||
self.assertEqual(p["error_class"], "authentication")
|
||||
self.assertEqual(p["message"], boundary.fixed_message("auth_invalid_token"))
|
||||
self.assertNotIn("supersecret", json.dumps(p))
|
||||
self.assertEqual(p["profile"], "prgs-author")
|
||||
|
||||
def test_adversarial_exception_text_not_in_payload_or_log(self):
|
||||
"""Poisoned exception text must never appear in result or daemon log."""
|
||||
|
||||
class PoisonedAuth(gitea_auth.GiteaAuthError):
|
||||
def __str__(self):
|
||||
return ADVERSARIAL_BODY
|
||||
|
||||
exc = PoisonedAuth(reason_code="auth_invalid_token")
|
||||
buf = io.StringIO()
|
||||
result = boundary.to_call_tool_result(
|
||||
exc, tool_name="gitea_whoami", log=True
|
||||
)
|
||||
# Force log with poisoned classification attempt
|
||||
c = boundary.classify_exception(exc)
|
||||
boundary.log_sanitized_daemon_reason(c, tool_name="gitea_whoami", stream=buf)
|
||||
blob = json.dumps(result.structuredContent) + result.content[0].text + buf.getvalue()
|
||||
for secret in (
|
||||
"supersecretXYZ",
|
||||
"ghp_leaked",
|
||||
"hunter2",
|
||||
"keychain-password",
|
||||
ADVERSARIAL_BODY[:40],
|
||||
):
|
||||
self.assertNotIn(secret, blob)
|
||||
self.assertIn("reason_code=auth_invalid_token", buf.getvalue())
|
||||
self.assertNotIn("detail=", buf.getvalue())
|
||||
|
||||
def test_keychain_content_not_in_daemon_log(self):
|
||||
buf = io.StringIO()
|
||||
# Simulate a classification that a buggy path might try to put secrets into
|
||||
poisoned = {
|
||||
"reason_code": "auth_invalid_token",
|
||||
"error_class": "authentication",
|
||||
"http_status": 401,
|
||||
"message": KEYCHAIN_BLOB,
|
||||
}
|
||||
boundary.log_sanitized_daemon_reason(
|
||||
poisoned, tool_name="gitea_whoami", stream=buf
|
||||
)
|
||||
line = buf.getvalue()
|
||||
self.assertNotIn("sekrit", line)
|
||||
self.assertNotIn("keychain-item", line)
|
||||
self.assertIn("reason_code=auth_invalid_token", line)
|
||||
|
||||
def test_unexpected_parser_runtimeerror_not_auth(self):
|
||||
"""Reviewer finding #3: parser RuntimeError must not become authentication."""
|
||||
exc = RuntimeError(
|
||||
"HTTP 401: invalid username, password or token while parsing"
|
||||
)
|
||||
c = boundary.classify_exception(exc)
|
||||
self.assertEqual(c["error_class"], "internal")
|
||||
self.assertEqual(c["reason_code"], "internal_error")
|
||||
self.assertNotEqual(c["error_class"], "authentication")
|
||||
self.assertFalse(boundary.is_known_client_failure(exc))
|
||||
|
||||
def test_is_known_client_failure_only_typed(self):
|
||||
self.assertTrue(
|
||||
boundary.is_known_client_failure(gitea_auth.GiteaAuthError())
|
||||
)
|
||||
self.assertTrue(
|
||||
boundary.is_known_client_failure(gitea_auth.GiteaAuthzError())
|
||||
)
|
||||
self.assertFalse(boundary.is_known_client_failure(RuntimeError("x")))
|
||||
self.assertFalse(boundary.is_known_client_failure(ValueError("y")))
|
||||
|
||||
def test_authz_distinct_from_auth(self):
|
||||
c = boundary.classify_exception(
|
||||
gitea_auth.GiteaAuthzError(reason_code="authz_denied")
|
||||
)
|
||||
self.assertEqual(c["error_class"], "authorization")
|
||||
self.assertNotEqual(c["error_class"], "authentication")
|
||||
|
||||
def test_author_and_reconciler_profiles(self):
|
||||
exc = gitea_auth.GiteaAuthError()
|
||||
for profile in ("prgs-author", "prgs-reconciler"):
|
||||
r = boundary.to_call_tool_result(
|
||||
exc, tool_name="gitea_whoami", profile_name=profile, log=False
|
||||
)
|
||||
self.assertEqual(r.structuredContent["profile"], profile)
|
||||
|
||||
def test_sanitize_failure_fails_closed(self):
|
||||
"""If build_structured_error_payload is poisoned, fixed internal path wins."""
|
||||
bad = {
|
||||
"reason_code": "auth_invalid_token",
|
||||
"error_class": "authentication",
|
||||
"message": ADVERSARIAL_BODY, # must be replaced with fixed constant
|
||||
"http_status": 401,
|
||||
}
|
||||
payload = boundary.build_structured_error_payload(bad)
|
||||
self.assertEqual(
|
||||
payload["message"], boundary.fixed_message("auth_invalid_token")
|
||||
)
|
||||
self.assertNotIn("supersecret", payload["message"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool.run boundary — framework semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestToolRunBoundary(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from mcp.server.fastmcp.tools.base import Tool
|
||||
|
||||
boundary.install_tool_run_boundary(Tool)
|
||||
self.Tool = Tool
|
||||
|
||||
def _tool(self, fn, name="demo_tool"):
|
||||
return self.Tool.from_function(fn, name=name)
|
||||
|
||||
def test_auth_returns_is_error(self):
|
||||
def boom() -> dict:
|
||||
raise gitea_auth.GiteaAuthError()
|
||||
|
||||
result = asyncio.run(self._tool(boom, "gitea_whoami").run({}, convert_result=True))
|
||||
self.assertTrue(result.isError)
|
||||
self.assertEqual(result.structuredContent["reason_code"], "auth_invalid_token")
|
||||
|
||||
def test_url_elicitation_re_raised(self):
|
||||
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||
from mcp.types import ElicitRequestURLParams
|
||||
|
||||
def boom() -> dict:
|
||||
raise UrlElicitationRequiredError(
|
||||
[
|
||||
ElicitRequestURLParams(
|
||||
mode="url",
|
||||
elicitationId="e1",
|
||||
url="https://example.invalid/elicit",
|
||||
message="need auth",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
tool = self._tool(boom, "elicitation_tool")
|
||||
|
||||
async def _run():
|
||||
return await tool.run({}, convert_result=True)
|
||||
|
||||
with self.assertRaises(UrlElicitationRequiredError):
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_transport_survives_second_call(self):
|
||||
state = {"n": 0}
|
||||
|
||||
def flaky() -> dict:
|
||||
state["n"] += 1
|
||||
if state["n"] == 1:
|
||||
raise gitea_auth.GiteaAuthError()
|
||||
return {"ok": True, "call": state["n"]}
|
||||
|
||||
tool = self._tool(flaky, "gitea_whoami")
|
||||
|
||||
async def _both():
|
||||
first = await tool.run({}, convert_result=True)
|
||||
second = await tool.run({}, convert_result=True)
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(_both())
|
||||
self.assertTrue(first.isError)
|
||||
self.assertEqual(first.structuredContent["error_class"], "authentication")
|
||||
self.assertFalse(getattr(second, "isError", False))
|
||||
self.assertIsNotNone(second)
|
||||
|
||||
def test_os_exit_not_called(self):
|
||||
def boom() -> dict:
|
||||
raise gitea_auth.GiteaAuthError()
|
||||
|
||||
with patch("os._exit") as mock_exit:
|
||||
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
|
||||
mock_exit.assert_not_called()
|
||||
self.assertTrue(result.isError)
|
||||
|
||||
def test_internal_not_labeled_auth(self):
|
||||
def boom() -> dict:
|
||||
raise KeyError("unexpected internal bug")
|
||||
|
||||
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
|
||||
self.assertTrue(result.isError)
|
||||
self.assertEqual(result.structuredContent["error_class"], "internal")
|
||||
self.assertEqual(result.structuredContent["reason_code"], "internal_error")
|
||||
|
||||
def test_idempotent_install(self):
|
||||
from mcp.server.fastmcp.tools.base import Tool
|
||||
|
||||
first = boundary.install_tool_run_boundary(Tool)
|
||||
second = boundary.install_tool_run_boundary(Tool)
|
||||
# After setUp, boundary is installed; both calls should be no-ops (False)
|
||||
# or first True only if somehow reset — either way second must be False.
|
||||
self.assertFalse(second)
|
||||
self.assertTrue(boundary.boundary_is_installed(Tool))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real stdio subprocess: auth error then successful second call
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestStdioTransportSurvival(unittest.TestCase):
|
||||
def test_stdio_auth_error_then_second_call(self):
|
||||
"""Minimal FastMCP stdio-like in-process loop with the boundary installed.
|
||||
|
||||
Uses Tool.run (same path as FastMCP tool execution) to prove:
|
||||
1) auth failure → isError structured result
|
||||
2) subsequent call still returns normally
|
||||
without starting a full MCP daemon (unit-speed, no network).
|
||||
"""
|
||||
from mcp.server.fastmcp.tools.base import Tool
|
||||
|
||||
boundary.install_tool_run_boundary(Tool)
|
||||
calls = {"n": 0}
|
||||
|
||||
def whoami() -> dict:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
# Simulate revoked credential → typed auth failure
|
||||
raise gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
|
||||
return {"authenticated": True, "username": "demo"}
|
||||
|
||||
tool = Tool.from_function(whoami, name="gitea_whoami")
|
||||
|
||||
async def session():
|
||||
r1 = await tool.run({}, convert_result=True)
|
||||
r2 = await tool.run({}, convert_result=True)
|
||||
return r1, r2
|
||||
|
||||
r1, r2 = asyncio.run(session())
|
||||
self.assertTrue(r1.isError)
|
||||
self.assertEqual(r1.structuredContent["reason_code"], "auth_invalid_token")
|
||||
self.assertTrue(r1.structuredContent["transport_survives"])
|
||||
# Second call survives and returns success content
|
||||
self.assertFalse(getattr(r2, "isError", False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provenance non-bypass
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNativeProvenanceNonBypass(unittest.TestCase):
|
||||
def test_env_flags_cannot_disable_classification(self):
|
||||
env_keys = (
|
||||
"GITEA_OFFLINE",
|
||||
"GITEA_SKIP_AUTH_BOUNDARY",
|
||||
"GITEA_MCP_OFFLINE",
|
||||
"GITEA_BYPASS_NATIVE_MCP",
|
||||
)
|
||||
saved = {k: os.environ.get(k) for k in env_keys}
|
||||
try:
|
||||
for k in env_keys:
|
||||
os.environ[k] = "1"
|
||||
exc = gitea_auth.GiteaAuthError()
|
||||
c = boundary.classify_exception(exc)
|
||||
self.assertEqual(c["error_class"], "authentication")
|
||||
r = boundary.to_call_tool_result(exc, log=False)
|
||||
self.assertTrue(r.isError)
|
||||
self.assertEqual(r.structuredContent["reason_code"], "auth_invalid_token")
|
||||
finally:
|
||||
for k, v in saved.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
def test_no_bypass_surface(self):
|
||||
for name in dir(boundary):
|
||||
lower = name.lower()
|
||||
self.assertFalse(
|
||||
lower.startswith("bypass") or lower.startswith("skip_native"),
|
||||
msg=f"unexpected bypass surface: {name}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_reliability regressions still hold for non-401 paths
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestApiReliabilityCompat(unittest.TestCase):
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_502_upstream_typed(self, mock_open):
|
||||
mock_open.side_effect = http_error(502, "bad gateway secret=xyz")
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
|
||||
self.assertNotIn("secret=xyz", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_auth_header_never_in_error(self, mock_open):
|
||||
mock_open.side_effect = http_error(400, "bad request")
|
||||
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Invariant tests pinning task_capability_map role assignments (#722/#723).
|
||||
|
||||
Added with the operator-authorized break-glass repair for incident #722:
|
||||
commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every
|
||||
configured merger profile forbids the review permissions, so no configured
|
||||
profile could resolve any formal review task (``matching_configured_profile``
|
||||
was empty repository-wide). These tests fail loudly if that class of
|
||||
regression recurs:
|
||||
|
||||
- every role-exclusive formal-review task must be satisfiable by at least one
|
||||
canonical role profile (permission AND role together);
|
||||
- the capability map must agree with ``role_session_router`` task sets;
|
||||
- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of
|
||||
970e68b, preserved by the repair);
|
||||
- merger profiles must not be able to resolve review_pr/approve_pr.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import gitea_config
|
||||
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
# Canonical role-profile permission shape. Mirrors the configured
|
||||
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||
# reviewers review/approve/request changes but never merge; mergers merge but
|
||||
# never review/approve/request changes.
|
||||
CANONICAL_ROLE_PROFILES = {
|
||||
"author": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
},
|
||||
"reviewer": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
},
|
||||
"merger": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
},
|
||||
"reconciler": {
|
||||
"allowed": [
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.branch.delete",
|
||||
"gitea.decision_lock.irrecoverable_recovery",
|
||||
],
|
||||
"forbidden": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive
|
||||
# handling for review work): permission alone is not enough — the profile's
|
||||
# role kind must also match, so both dimensions are pinned here.
|
||||
FORMAL_REVIEW_TASKS = (
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
)
|
||||
|
||||
|
||||
def _profile_satisfies(role_name, task):
|
||||
"""True when the canonical *role_name* profile can perform *task*."""
|
||||
profile = CANONICAL_ROLE_PROFILES[role_name]
|
||||
ok, _reason = gitea_config.check_operation(
|
||||
required_permission(task), profile["allowed"], profile["forbidden"]
|
||||
)
|
||||
return ok and role_name == required_role(task)
|
||||
|
||||
|
||||
class TestFormalReviewProfileCoverage(unittest.TestCase):
|
||||
"""#722: some configured profile must be able to formally review."""
|
||||
|
||||
def test_every_formal_review_task_has_a_satisfying_role_profile(self):
|
||||
for task in FORMAL_REVIEW_TASKS:
|
||||
with self.subTest(task=task):
|
||||
satisfying = [
|
||||
role
|
||||
for role in CANONICAL_ROLE_PROFILES
|
||||
if _profile_satisfies(role, task)
|
||||
]
|
||||
self.assertTrue(
|
||||
satisfying,
|
||||
f"no canonical role profile satisfies both permission "
|
||||
f"{required_permission(task)!r} and role "
|
||||
f"{required_role(task)!r} for task {task!r} — formal "
|
||||
f"review would be impossible for every configured "
|
||||
f"profile (incident #722)",
|
||||
)
|
||||
|
||||
def test_formal_review_tasks_are_reviewer_role(self):
|
||||
for task in FORMAL_REVIEW_TASKS:
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(required_role(task), "reviewer")
|
||||
|
||||
|
||||
class TestMapRouterAgreement(unittest.TestCase):
|
||||
"""#723 AC2: the map and the role session router must not drift."""
|
||||
|
||||
def test_reviewer_tasks_map_to_reviewer_role(self):
|
||||
for task in sorted(REVIEWER_TASKS):
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(
|
||||
required_role(task),
|
||||
"reviewer",
|
||||
f"router classifies {task!r} as a reviewer task but the "
|
||||
f"capability map assigns role {required_role(task)!r}",
|
||||
)
|
||||
|
||||
def test_merger_tasks_map_to_merger_role(self):
|
||||
for task in sorted(MERGER_TASKS):
|
||||
with self.subTest(task=task):
|
||||
self.assertEqual(
|
||||
required_role(task),
|
||||
"merger",
|
||||
f"router classifies {task!r} as a merger task but the "
|
||||
f"capability map assigns role {required_role(task)!r}",
|
||||
)
|
||||
|
||||
|
||||
class TestMergerBoundary(unittest.TestCase):
|
||||
"""Preserve the legitimate hunk of 970e68b and the merger fence."""
|
||||
|
||||
def test_adopt_merger_pr_lease_requires_merger_role(self):
|
||||
self.assertEqual(required_role("adopt_merger_pr_lease"), "merger")
|
||||
self.assertEqual(
|
||||
required_permission("adopt_merger_pr_lease"), "gitea.pr.comment"
|
||||
)
|
||||
|
||||
def test_merge_pr_requires_merger_role(self):
|
||||
self.assertEqual(required_role("merge_pr"), "merger")
|
||||
self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge")
|
||||
|
||||
def test_merger_profile_cannot_resolve_formal_review_tasks(self):
|
||||
merger = CANONICAL_ROLE_PROFILES["merger"]
|
||||
for task in ("review_pr", "approve_pr", "request_changes_pr"):
|
||||
with self.subTest(task=task):
|
||||
ok, reason = gitea_config.check_operation(
|
||||
required_permission(task),
|
||||
merger["allowed"],
|
||||
merger["forbidden"],
|
||||
)
|
||||
self.assertFalse(
|
||||
ok,
|
||||
f"merger profile must not hold {task!r} permission "
|
||||
f"(got reason {reason!r})",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332).
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
|
||||
|
||||
Extends the single-terminal-decision machinery (#211): after a live
|
||||
REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence
|
||||
for that same PR may continue; a different PR can never be reviewed or
|
||||
merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is
|
||||
suppressed.
|
||||
REQUEST_CHANGES on a PR head the same-head path must stop; after an APPROVE
|
||||
only the merge sequence for that same PR may continue; #693 allows a
|
||||
*different* PR to be mark_ready/review in the same durable lock (cross-PR
|
||||
isolation); duplicate REQUEST_CHANGES at an unchanged head is suppressed.
|
||||
"""
|
||||
import os
|
||||
import unittest
|
||||
@@ -15,7 +15,10 @@ import mcp_server
|
||||
HEAD_SHA = "a" * 40
|
||||
|
||||
|
||||
def _lock(mutations=None, correction=False):
|
||||
def _lock(mutations=None, correction=False, correction_pr=None):
|
||||
mutations = list(mutations or [])
|
||||
if correction and correction_pr is None and mutations:
|
||||
correction_pr = mutations[-1].get("pr_number")
|
||||
return {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
@@ -29,9 +32,11 @@ def _lock(mutations=None, correction=False):
|
||||
"ready_remote": None,
|
||||
"ready_org": None,
|
||||
"ready_repo": None,
|
||||
"live_mutations": list(mutations or []),
|
||||
"live_mutations": mutations,
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": None,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": correction_pr if correction else None,
|
||||
"correction_head_sha": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -64,25 +69,35 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||
|
||||
def test_request_changes_blocks_everything(self):
|
||||
def test_request_changes_blocks_same_pr_allows_other_pr_review(self):
|
||||
_seed([RC_A])
|
||||
# Same PR: still hard-stopped for mark_ready/review/merge.
|
||||
for op in ("merge", "mark_ready", "review"):
|
||||
for pr in (5, 6):
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op)
|
||||
self.assertTrue(reasons, f"{op}/{pr}")
|
||||
self.assertIn("request_changes on PR #5", reasons[0])
|
||||
self.assertIn("#332", reasons[0])
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(5, op)
|
||||
self.assertTrue(reasons, f"{op}/5")
|
||||
self.assertIn("request_changes on PR #5", reasons[0])
|
||||
self.assertIn("#332", reasons[0])
|
||||
# #693: different PR may mark_ready / review (not merge via hard-stop alone).
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||
# Merge of an unapproved other PR remains blocked by hard-stop path
|
||||
# (last terminal is not approve of PR 6).
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
|
||||
def test_approve_allows_same_pr_merge_only(self):
|
||||
def test_approve_allows_same_pr_merge_and_other_pr_review(self):
|
||||
_seed([APPROVED_A])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
|
||||
# #693 cross-PR isolation: other PR formal review path is open.
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||
|
||||
def test_correction_reopens_review_path_only(self):
|
||||
_seed([RC_A], correction=True)
|
||||
@@ -90,9 +105,11 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
|
||||
# correction never opens a cross-PR merge
|
||||
# scoped correction never opens a cross-PR merge
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
# unscoped/cross-PR correction must not lift PR 6 via correction alone
|
||||
# (PR 6 is allowed by isolation, not by correction scope)
|
||||
|
||||
|
||||
class TestMergeHardStopWiring(unittest.TestCase):
|
||||
@@ -126,14 +143,29 @@ class TestMarkFinalHardStopWiring(unittest.TestCase):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
||||
def test_mark_ready_same_pr_blocked_after_terminal_mutation(self):
|
||||
_seed([RC_A])
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="approve", remote="prgs")
|
||||
pr_number=5, action="approve", remote="prgs",
|
||||
expected_head_sha=HEAD_SHA)
|
||||
self.assertFalse(result["marked_ready"])
|
||||
self.assertTrue(
|
||||
any("#332" in r for r in result["reasons"]), result["reasons"])
|
||||
|
||||
def test_mark_ready_other_pr_allowed_after_foreign_terminal(self):
|
||||
"""#693: foreign open-PR terminal must not block mark_final on PR B."""
|
||||
_seed([RC_A])
|
||||
no_lease = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), \
|
||||
patch("mcp_server._pr_work_lease_reviewer_block",
|
||||
return_value=no_lease), \
|
||||
patch.object(mcp_server, "gitea_check_pr_eligibility",
|
||||
return_value={"head_sha": HEAD_SHA, "eligible": True}):
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="approve", remote="prgs",
|
||||
expected_head_sha=HEAD_SHA)
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
|
||||
|
||||
def _feedback(blocking, stale=False, success=True):
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user