fix: bind reviewer mutations to active branches worktree (Closes #503)
Reviewer mutation tools now resolve workspace from worktree_path, GITEA_ACTIVE_WORKTREE, or the session reviewer lease before preflight and branches-only validation. Preflight rejects metadata-only worktree_path bindings that would mislead runtime_context while mutations still inspect the MCP process root. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+134
-5
@@ -208,6 +208,71 @@ def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewer_session_worktree() -> str | None:
|
||||||
|
session = reviewer_pr_lease.get_session_lease()
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
worktree = (session.get("worktree") or "").strip()
|
||||||
|
return worktree or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_reviewer_mutation_worktree_path(
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
worktree: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the workspace inspected before reviewer mutations (#503)."""
|
||||||
|
return reviewer_mutation_workspace.resolve_reviewer_mutation_workspace(
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
worktree=worktree,
|
||||||
|
process_project_root=PROJECT_ROOT,
|
||||||
|
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
|
||||||
|
session_lease_worktree=_reviewer_session_worktree(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assess_reviewer_mutation_workspace(
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
worktree: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
git_state = issue_lock_worktree.read_worktree_git_state(
|
||||||
|
_resolve_reviewer_mutation_worktree_path(worktree_path, worktree)
|
||||||
|
)
|
||||||
|
return reviewer_mutation_workspace.assess_reviewer_mutation_workspace(
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
worktree=worktree,
|
||||||
|
process_project_root=PROJECT_ROOT,
|
||||||
|
active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV),
|
||||||
|
session_lease_worktree=_reviewer_session_worktree(),
|
||||||
|
current_branch=git_state.get("current_branch"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_reviewer_mutation_workspace(
|
||||||
|
remote: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
worktree: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Bind reviewer mutations to the active branches/ worktree (#503)."""
|
||||||
|
assessment = _assess_reviewer_mutation_workspace(worktree_path, worktree)
|
||||||
|
if assessment["block"]:
|
||||||
|
raise RuntimeError(
|
||||||
|
reviewer_mutation_workspace.format_stale_mcp_workspace_binding_error(
|
||||||
|
process_project_root=assessment["process_project_root"],
|
||||||
|
mutation_workspace=assessment["mutation_workspace"],
|
||||||
|
declared_worktree_path=assessment.get("declared_worktree_path"),
|
||||||
|
metadata_only=assessment.get("metadata_only", False),
|
||||||
|
)
|
||||||
|
+ (
|
||||||
|
f" Details: {'; '.join(assessment['reasons'])}"
|
||||||
|
if assessment.get("reasons")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
resolved = assessment["mutation_workspace"]
|
||||||
|
verify_preflight_purity(remote, worktree_path=resolved)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def _get_git_root(path: str) -> str | None:
|
def _get_git_root(path: str) -> str | None:
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
@@ -332,6 +397,22 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
|
|||||||
"Active task workspace has tracked file edits before mutation "
|
"Active task workspace has tracked file edits before mutation "
|
||||||
f"({_format_preflight_workspace_details(workspace_details)})"
|
f"({_format_preflight_workspace_details(workspace_details)})"
|
||||||
)
|
)
|
||||||
|
if _preflight_resolved_role == "reviewer":
|
||||||
|
binding = reviewer_mutation_workspace.assess_metadata_only_worktree_binding(
|
||||||
|
declared_worktree_path=worktree_path,
|
||||||
|
mutation_workspace=_resolve_reviewer_mutation_worktree_path(),
|
||||||
|
process_project_root=PROJECT_ROOT,
|
||||||
|
)
|
||||||
|
if binding.get("block"):
|
||||||
|
reasons.append(binding["reasons"][0])
|
||||||
|
reasons.append(
|
||||||
|
reviewer_mutation_workspace.format_stale_mcp_workspace_binding_error(
|
||||||
|
process_project_root=PROJECT_ROOT,
|
||||||
|
mutation_workspace=binding["mutation_workspace"],
|
||||||
|
declared_worktree_path=binding.get("declared_worktree_path"),
|
||||||
|
metadata_only=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"preflight_ready": not reasons,
|
"preflight_ready": not reasons,
|
||||||
"preflight_block_reasons": reasons,
|
"preflight_block_reasons": reasons,
|
||||||
@@ -542,6 +623,7 @@ import author_mutation_worktree # noqa: E402
|
|||||||
import issue_claim_heartbeat # noqa: E402
|
import issue_claim_heartbeat # noqa: E402
|
||||||
import issue_work_duplicate_gate # noqa: E402
|
import issue_work_duplicate_gate # noqa: E402
|
||||||
import reviewer_pr_lease # noqa: E402
|
import reviewer_pr_lease # noqa: E402
|
||||||
|
import reviewer_mutation_workspace # noqa: E402
|
||||||
import merged_cleanup_reconcile # noqa: E402
|
import merged_cleanup_reconcile # noqa: E402
|
||||||
import reconciler_profile # noqa: E402
|
import reconciler_profile # noqa: E402
|
||||||
import reconciliation_workflow # noqa: E402
|
import reconciliation_workflow # noqa: E402
|
||||||
@@ -2639,9 +2721,28 @@ def _evaluate_pr_review_submission(
|
|||||||
*,
|
*,
|
||||||
live: bool,
|
live: bool,
|
||||||
final_review_decision_ready: bool = False,
|
final_review_decision_ready: bool = False,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared gate chain for live submit and dry-run review tools."""
|
"""Shared gate chain for live submit and dry-run review tools."""
|
||||||
verify_preflight_purity(remote)
|
try:
|
||||||
|
_verify_reviewer_mutation_workspace(
|
||||||
|
remote, worktree_path=worktree_path
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {
|
||||||
|
"requested_action": (action or "").strip().lower(),
|
||||||
|
"performed": False,
|
||||||
|
"dry_run": not live,
|
||||||
|
"would_perform": False,
|
||||||
|
"authenticated_user": None,
|
||||||
|
"profile_name": get_profile()["profile_name"],
|
||||||
|
"pr_author": None,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_sha": None,
|
||||||
|
"expected_head_sha": expected_head_sha,
|
||||||
|
"remote": remote if remote in REMOTES else None,
|
||||||
|
"reasons": [str(exc)],
|
||||||
|
}
|
||||||
action = (action or "").strip().lower()
|
action = (action or "").strip().lower()
|
||||||
result = {
|
result = {
|
||||||
"requested_action": action,
|
"requested_action": action,
|
||||||
@@ -2832,9 +2933,16 @@ def gitea_mark_final_review_decision(
|
|||||||
remote: str = "dadeschools",
|
remote: str = "dadeschools",
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Mark validation complete; the final review decision is ready to submit."""
|
"""Mark validation complete; the final review decision is ready to submit."""
|
||||||
action = (action or "").strip().lower()
|
action = (action or "").strip().lower()
|
||||||
|
try:
|
||||||
|
_verify_reviewer_mutation_workspace(
|
||||||
|
remote, worktree_path=worktree_path
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {"marked_ready": False, "reasons": [str(exc)]}
|
||||||
lock = _load_review_decision_lock()
|
lock = _load_review_decision_lock()
|
||||||
if lock is None:
|
if lock is None:
|
||||||
return {
|
return {
|
||||||
@@ -3041,6 +3149,7 @@ def gitea_dry_run_pr_review(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate review submission mechanics without a live PR mutation."""
|
"""Validate review submission mechanics without a live PR mutation."""
|
||||||
return _evaluate_pr_review_submission(
|
return _evaluate_pr_review_submission(
|
||||||
@@ -3053,6 +3162,7 @@ def gitea_dry_run_pr_review(
|
|||||||
org=org,
|
org=org,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
live=False,
|
live=False,
|
||||||
|
worktree_path=worktree_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3068,6 +3178,7 @@ def gitea_submit_pr_review(
|
|||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
final_review_decision_ready: bool = False,
|
final_review_decision_ready: bool = False,
|
||||||
|
worktree_path: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Gated PR review mutation: comment findings, request changes, or approve.
|
"""Gated PR review mutation: comment findings, request changes, or approve.
|
||||||
|
|
||||||
@@ -3086,6 +3197,7 @@ def gitea_submit_pr_review(
|
|||||||
repo=repo,
|
repo=repo,
|
||||||
live=True,
|
live=True,
|
||||||
final_review_decision_ready=final_review_decision_ready,
|
final_review_decision_ready=final_review_decision_ready,
|
||||||
|
worktree_path=worktree_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4982,7 +5094,17 @@ def gitea_acquire_reviewer_pr_lease(
|
|||||||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_preflight_purity(remote)
|
try:
|
||||||
|
resolved_worktree = _verify_reviewer_mutation_workspace(
|
||||||
|
remote, worktree=worktree
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"acquired": False,
|
||||||
|
"reasons": [str(exc)],
|
||||||
|
"permission_report": None,
|
||||||
|
}
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
@@ -5000,7 +5122,7 @@ def gitea_acquire_reviewer_pr_lease(
|
|||||||
session_id=sid,
|
session_id=sid,
|
||||||
repo=repo_label,
|
repo=repo_label,
|
||||||
issue_number=issue_number,
|
issue_number=issue_number,
|
||||||
worktree=worktree,
|
worktree=resolved_worktree,
|
||||||
candidate_head=candidate_head,
|
candidate_head=candidate_head,
|
||||||
target_branch=target_branch,
|
target_branch=target_branch,
|
||||||
target_branch_sha=target_branch_sha,
|
target_branch_sha=target_branch_sha,
|
||||||
@@ -5032,7 +5154,7 @@ def gitea_acquire_reviewer_pr_lease(
|
|||||||
"session_id": sid,
|
"session_id": sid,
|
||||||
"reviewer_identity": identity,
|
"reviewer_identity": identity,
|
||||||
"profile": profile.get("profile_name"),
|
"profile": profile.get("profile_name"),
|
||||||
"worktree": worktree,
|
"worktree": resolved_worktree,
|
||||||
"phase": "claimed",
|
"phase": "claimed",
|
||||||
"candidate_head": candidate_head,
|
"candidate_head": candidate_head,
|
||||||
"target_branch": target_branch,
|
"target_branch": target_branch,
|
||||||
@@ -5083,7 +5205,14 @@ def gitea_heartbeat_reviewer_pr_lease(
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_preflight_purity(remote)
|
try:
|
||||||
|
_verify_reviewer_mutation_workspace(
|
||||||
|
remote,
|
||||||
|
worktree=worktree,
|
||||||
|
worktree_path=session.get("worktree"),
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return {"success": False, "posted": False, "reasons": [str(exc)]}
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
body = reviewer_pr_lease.format_lease_body(
|
body = reviewer_pr_lease.format_lease_body(
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Reviewer mutation workspace binding (#503).
|
||||||
|
|
||||||
|
Reviewer mutation tools must validate the same branches/ worktree that
|
||||||
|
read-only preflight and runtime_context inspect — not only the MCP server
|
||||||
|
process root.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import author_mutation_worktree as amw
|
||||||
|
|
||||||
|
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_reviewer_mutation_workspace(
|
||||||
|
*,
|
||||||
|
worktree_path: str | None,
|
||||||
|
worktree: str | None,
|
||||||
|
process_project_root: str,
|
||||||
|
active_worktree_env: str | None = None,
|
||||||
|
session_lease_worktree: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the workspace inspected before reviewer mutations."""
|
||||||
|
for candidate in (
|
||||||
|
worktree_path,
|
||||||
|
worktree,
|
||||||
|
active_worktree_env,
|
||||||
|
session_lease_worktree,
|
||||||
|
):
|
||||||
|
text = (candidate or "").strip()
|
||||||
|
if text:
|
||||||
|
return os.path.realpath(os.path.abspath(text))
|
||||||
|
return os.path.realpath(process_project_root)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_metadata_only_worktree_binding(
|
||||||
|
*,
|
||||||
|
declared_worktree_path: str | None,
|
||||||
|
mutation_workspace: str,
|
||||||
|
process_project_root: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Fail closed when declared worktree_path would not redirect mutations."""
|
||||||
|
declared = (declared_worktree_path or "").strip()
|
||||||
|
process_root = os.path.realpath(process_project_root)
|
||||||
|
mutation_root = os.path.realpath(mutation_workspace)
|
||||||
|
if not declared:
|
||||||
|
return {"block": False, "reasons": [], "metadata_only": False}
|
||||||
|
|
||||||
|
declared_root = os.path.realpath(os.path.abspath(declared))
|
||||||
|
if declared_root == mutation_root:
|
||||||
|
return {"block": False, "reasons": [], "metadata_only": False}
|
||||||
|
|
||||||
|
if declared_root != process_root and mutation_root == process_root:
|
||||||
|
return {
|
||||||
|
"block": True,
|
||||||
|
"metadata_only": True,
|
||||||
|
"reasons": [
|
||||||
|
"worktree_path is metadata-only for reviewer mutations: preflight "
|
||||||
|
f"inspected '{declared_root}' but mutation tools would still "
|
||||||
|
f"validate MCP server process root '{process_root}'"
|
||||||
|
],
|
||||||
|
"declared_worktree_path": declared_root,
|
||||||
|
"mutation_workspace": mutation_root,
|
||||||
|
"process_project_root": process_root,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"block": False, "reasons": [], "metadata_only": False}
|
||||||
|
|
||||||
|
|
||||||
|
def format_stale_mcp_workspace_binding_error(
|
||||||
|
*,
|
||||||
|
process_project_root: str,
|
||||||
|
mutation_workspace: str,
|
||||||
|
declared_worktree_path: str | None = None,
|
||||||
|
metadata_only: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Canonical handoff when reviewer mutations bind to the wrong workspace."""
|
||||||
|
process_root = os.path.realpath(process_project_root)
|
||||||
|
mutation_root = os.path.realpath(mutation_workspace)
|
||||||
|
declared = (declared_worktree_path or "").strip()
|
||||||
|
declared_root = (
|
||||||
|
os.path.realpath(os.path.abspath(declared)) if declared else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if metadata_only and declared_root:
|
||||||
|
lead = (
|
||||||
|
"Reviewer mutation workspace mismatch (#503): worktree_path affected "
|
||||||
|
f"read-only preflight only ('{declared_root}') while mutation tools "
|
||||||
|
f"still inspect MCP server process root '{process_root}'."
|
||||||
|
)
|
||||||
|
elif mutation_root == process_root:
|
||||||
|
lead = (
|
||||||
|
"Reviewer mutation workspace mismatch (#503): mutation tools are "
|
||||||
|
f"bound to the stable control checkout '{process_root}' instead of "
|
||||||
|
"an active branches/ review worktree."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lead = (
|
||||||
|
"Reviewer mutation workspace mismatch (#503): mutation workspace "
|
||||||
|
f"'{mutation_root}' does not match the declared active task worktree."
|
||||||
|
)
|
||||||
|
|
||||||
|
fixes = (
|
||||||
|
"Fix: relaunch or reconnect the MCP server from the branches/ review "
|
||||||
|
"worktree, set GITEA_ACTIVE_WORKTREE to that path, pass worktree_path "
|
||||||
|
"to reviewer mutation tools, or acquire a reviewer PR lease from the "
|
||||||
|
"same branches/ worktree so mutations inherit the validated workspace."
|
||||||
|
)
|
||||||
|
return f"{lead} {fixes}"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_reviewer_mutation_workspace(
|
||||||
|
*,
|
||||||
|
worktree_path: str | None,
|
||||||
|
worktree: str | None,
|
||||||
|
process_project_root: str,
|
||||||
|
active_worktree_env: str | None = None,
|
||||||
|
session_lease_worktree: str | None = None,
|
||||||
|
current_branch: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Evaluate reviewer mutation workspace binding before preflight/mutation."""
|
||||||
|
mutation_workspace = resolve_reviewer_mutation_workspace(
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
worktree=worktree,
|
||||||
|
process_project_root=process_project_root,
|
||||||
|
active_worktree_env=active_worktree_env,
|
||||||
|
session_lease_worktree=session_lease_worktree,
|
||||||
|
)
|
||||||
|
process_root = os.path.realpath(process_project_root)
|
||||||
|
metadata = assess_metadata_only_worktree_binding(
|
||||||
|
declared_worktree_path=worktree_path,
|
||||||
|
mutation_workspace=mutation_workspace,
|
||||||
|
process_project_root=process_root,
|
||||||
|
)
|
||||||
|
reasons = list(metadata.get("reasons") or [])
|
||||||
|
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||||
|
branches = amw.assess_author_mutation_worktree(
|
||||||
|
workspace_path=mutation_workspace,
|
||||||
|
project_root=canonical_root,
|
||||||
|
current_branch=current_branch,
|
||||||
|
)
|
||||||
|
if branches["block"]:
|
||||||
|
reasons.extend(branches["reasons"])
|
||||||
|
|
||||||
|
block = bool(reasons)
|
||||||
|
return {
|
||||||
|
"block": block,
|
||||||
|
"reasons": reasons,
|
||||||
|
"mutation_workspace": mutation_workspace,
|
||||||
|
"process_project_root": process_root,
|
||||||
|
"canonical_repo_root": canonical_root,
|
||||||
|
"metadata_only": metadata.get("metadata_only", False),
|
||||||
|
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||||
|
"under_branches": branches.get("under_branches", False),
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Tests for reviewer mutation workspace binding (#503)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
import reviewer_mutation_workspace as rmw # noqa: E402
|
||||||
|
import reviewer_pr_lease # noqa: E402
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if REPO_ROOT.parent.name == "branches":
|
||||||
|
CONTROL_ROOT = str(REPO_ROOT.parent.parent)
|
||||||
|
else:
|
||||||
|
CONTROL_ROOT = str(REPO_ROOT)
|
||||||
|
BRANCHES_WORKTREE = f"{CONTROL_ROOT}/branches/review-pr503-submit"
|
||||||
|
MCP_PROCESS_ROOT = CONTROL_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerMutationWorkspaceModule(unittest.TestCase):
|
||||||
|
def test_resolve_prefers_explicit_worktree_path(self):
|
||||||
|
resolved = rmw.resolve_reviewer_mutation_workspace(
|
||||||
|
worktree_path=BRANCHES_WORKTREE,
|
||||||
|
worktree="/ignored",
|
||||||
|
process_project_root=MCP_PROCESS_ROOT,
|
||||||
|
active_worktree_env="/env/path",
|
||||||
|
session_lease_worktree="/lease/path",
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved, os.path.realpath(BRANCHES_WORKTREE))
|
||||||
|
|
||||||
|
def test_resolve_falls_back_to_session_lease(self):
|
||||||
|
resolved = rmw.resolve_reviewer_mutation_workspace(
|
||||||
|
worktree_path=None,
|
||||||
|
worktree=None,
|
||||||
|
process_project_root=MCP_PROCESS_ROOT,
|
||||||
|
session_lease_worktree=BRANCHES_WORKTREE,
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved, os.path.realpath(BRANCHES_WORKTREE))
|
||||||
|
|
||||||
|
def test_metadata_only_binding_detected(self):
|
||||||
|
assessment = rmw.assess_metadata_only_worktree_binding(
|
||||||
|
declared_worktree_path=BRANCHES_WORKTREE,
|
||||||
|
mutation_workspace=MCP_PROCESS_ROOT,
|
||||||
|
process_project_root=MCP_PROCESS_ROOT,
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertTrue(assessment["metadata_only"])
|
||||||
|
self.assertIn("metadata-only", assessment["reasons"][0])
|
||||||
|
|
||||||
|
def test_canonical_error_mentions_fix_paths(self):
|
||||||
|
msg = rmw.format_stale_mcp_workspace_binding_error(
|
||||||
|
process_project_root=MCP_PROCESS_ROOT,
|
||||||
|
mutation_workspace=MCP_PROCESS_ROOT,
|
||||||
|
declared_worktree_path=BRANCHES_WORKTREE,
|
||||||
|
metadata_only=True,
|
||||||
|
)
|
||||||
|
self.assertIn("GITEA_ACTIVE_WORKTREE", msg)
|
||||||
|
self.assertIn("worktree_path", msg)
|
||||||
|
self.assertIn("branches/", msg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerMutationWorkspaceIntegration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
self._saved = {
|
||||||
|
"whoami_called": srv._preflight_whoami_called,
|
||||||
|
"capability_called": srv._preflight_capability_called,
|
||||||
|
"resolved_role": srv._preflight_resolved_role,
|
||||||
|
"whoami_violation": srv._preflight_whoami_violation,
|
||||||
|
"whoami_files": list(srv._preflight_whoami_violation_files),
|
||||||
|
"capability_violation": srv._preflight_capability_violation,
|
||||||
|
"capability_files": list(srv._preflight_capability_violation_files),
|
||||||
|
"reviewer_files": list(srv._preflight_reviewer_violation_files),
|
||||||
|
"in_test": srv._preflight_in_test_mode,
|
||||||
|
}
|
||||||
|
srv._preflight_whoami_called = True
|
||||||
|
srv._preflight_capability_called = True
|
||||||
|
srv._preflight_resolved_role = "reviewer"
|
||||||
|
srv._preflight_whoami_violation = True
|
||||||
|
srv._preflight_whoami_violation_files = ["gitea_mcp_server.py"]
|
||||||
|
srv._preflight_capability_violation = False
|
||||||
|
srv._preflight_capability_violation_files = []
|
||||||
|
srv._preflight_reviewer_violation_files = []
|
||||||
|
srv._preflight_in_test_mode = lambda: False
|
||||||
|
self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False)
|
||||||
|
self._env_patch.start()
|
||||||
|
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||||
|
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
reviewer_pr_lease.clear_session_lease()
|
||||||
|
srv._preflight_whoami_called = self._saved["whoami_called"]
|
||||||
|
srv._preflight_capability_called = self._saved["capability_called"]
|
||||||
|
srv._preflight_resolved_role = self._saved["resolved_role"]
|
||||||
|
srv._preflight_whoami_violation = self._saved["whoami_violation"]
|
||||||
|
srv._preflight_whoami_violation_files = self._saved["whoami_files"]
|
||||||
|
srv._preflight_capability_violation = self._saved["capability_violation"]
|
||||||
|
srv._preflight_capability_violation_files = self._saved["capability_files"]
|
||||||
|
srv._preflight_reviewer_violation_files = self._saved["reviewer_files"]
|
||||||
|
srv._preflight_in_test_mode = self._saved["in_test"]
|
||||||
|
self._env_patch.stop()
|
||||||
|
|
||||||
|
@mock.patch("subprocess.run")
|
||||||
|
@mock.patch("os.path.isdir", return_value=True)
|
||||||
|
@mock.patch("os.path.exists", return_value=True)
|
||||||
|
def test_reviewer_mutation_uses_declared_branches_worktree(
|
||||||
|
self, _exists, _isdir, mock_run
|
||||||
|
):
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||||
|
)
|
||||||
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
|
resolved = srv._verify_reviewer_mutation_workspace(
|
||||||
|
"prgs", worktree_path=BRANCHES_WORKTREE
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved, os.path.realpath(BRANCHES_WORKTREE))
|
||||||
|
|
||||||
|
def test_control_checkout_blocks_reviewer_mutations(self):
|
||||||
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
srv._verify_reviewer_mutation_workspace("prgs")
|
||||||
|
self.assertIn("stable control checkout", str(ctx.exception))
|
||||||
|
self.assertIn("GITEA_ACTIVE_WORKTREE", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_preflight_and_mutation_agree_on_workspace(self):
|
||||||
|
os.environ["GITEA_ACTIVE_WORKTREE"] = BRANCHES_WORKTREE
|
||||||
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
|
with mock.patch.object(
|
||||||
|
srv,
|
||||||
|
"_get_git_root",
|
||||||
|
return_value=BRANCHES_WORKTREE,
|
||||||
|
):
|
||||||
|
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
||||||
|
resolved = srv._resolve_reviewer_mutation_worktree_path(
|
||||||
|
worktree_path=BRANCHES_WORKTREE
|
||||||
|
)
|
||||||
|
self.assertTrue(status["preflight_ready"])
|
||||||
|
self.assertEqual(
|
||||||
|
status["preflight_workspace"]["active_task_workspace_root"],
|
||||||
|
os.path.realpath(BRANCHES_WORKTREE),
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved, os.path.realpath(BRANCHES_WORKTREE))
|
||||||
|
|
||||||
|
def test_metadata_only_worktree_path_blocks_preflight_ready(self):
|
||||||
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
|
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
||||||
|
self.assertFalse(status["preflight_ready"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("metadata-only" in reason for reason in status["preflight_block_reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("subprocess.run")
|
||||||
|
@mock.patch("os.path.isdir", return_value=True)
|
||||||
|
@mock.patch("os.path.exists", return_value=True)
|
||||||
|
def test_dry_run_inherits_session_lease_worktree(
|
||||||
|
self, _exists, _isdir, mock_run
|
||||||
|
):
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||||
|
)
|
||||||
|
reviewer_pr_lease.record_session_lease({
|
||||||
|
"pr_number": 503,
|
||||||
|
"session_id": "sess-503",
|
||||||
|
"worktree": BRANCHES_WORKTREE,
|
||||||
|
})
|
||||||
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
|
with mock.patch.object(
|
||||||
|
srv,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={
|
||||||
|
"eligible": False,
|
||||||
|
"reasons": ["stub"],
|
||||||
|
"authenticated_user": "reviewer",
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"pr_author": "author",
|
||||||
|
"head_sha": "abc123",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
result = srv.gitea_dry_run_pr_review(
|
||||||
|
pr_number=503,
|
||||||
|
action="comment",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertNotIn("stable control checkout", " ".join(result.get("reasons") or []))
|
||||||
|
self.assertNotIn("metadata-only", " ".join(result.get("reasons") or []))
|
||||||
|
|
||||||
|
@mock.patch("subprocess.run")
|
||||||
|
@mock.patch("os.path.isdir", return_value=True)
|
||||||
|
@mock.patch("os.path.exists", return_value=True)
|
||||||
|
def test_dry_run_fails_closed_from_control_checkout_without_binding(
|
||||||
|
self, _exists, _isdir, mock_run
|
||||||
|
):
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||||
|
)
|
||||||
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
|
result = srv.gitea_dry_run_pr_review(
|
||||||
|
pr_number=503,
|
||||||
|
action="comment",
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
reasons = " ".join(result.get("reasons") or [])
|
||||||
|
self.assertIn("stable control checkout", reasons)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user