Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
324b4b3e93 |
+64
-338
@@ -4162,335 +4162,6 @@ def gitea_submit_pr_review(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_save_review_draft(
|
||||
pr_number: int,
|
||||
action: str,
|
||||
body: str = "",
|
||||
expected_head_sha: str | None = None,
|
||||
validation_commands: str | None = None,
|
||||
validation_results: str | None = None,
|
||||
blocker_reason: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Save a prepared review verdict as non-terminal draft evidence without submitting a formal review.
|
||||
|
||||
This permits resuming the review later (e.g. after a stale runtime restart or connection drop).
|
||||
"""
|
||||
action = (action or "").strip().lower()
|
||||
if action not in _REVIEW_ACTIONS:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [
|
||||
f"unknown review action '{action}'; expected one of "
|
||||
f"{sorted(_REVIEW_ACTIONS)}"
|
||||
],
|
||||
}
|
||||
|
||||
# 1. Resolve remote context
|
||||
try:
|
||||
h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "reasons": [str(exc)]}
|
||||
|
||||
auth = _auth(h)
|
||||
|
||||
# 2. Fetch current PR details to get target base branch and base branch SHA
|
||||
try:
|
||||
pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
|
||||
pr = api_request("GET", pr_url, auth) or {}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
|
||||
}
|
||||
|
||||
current_head = (pr.get("head") or {}).get("sha")
|
||||
target_branch = (pr.get("base") or {}).get("ref")
|
||||
target_branch_sha = (pr.get("base") or {}).get("sha")
|
||||
|
||||
if expected_head_sha and current_head and expected_head_sha != current_head:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [
|
||||
f"expected_head_sha '{expected_head_sha}' does not match current PR head SHA '{current_head}'"
|
||||
],
|
||||
}
|
||||
|
||||
# 3. Resolve worktree path
|
||||
try:
|
||||
resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
|
||||
except Exception as exc:
|
||||
return {"success": False, "reasons": [f"failed to resolve worktree path: {str(exc)}"]}
|
||||
|
||||
# 4. Save payload to durable session state
|
||||
profile = get_profile()
|
||||
payload = {
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"body": body,
|
||||
"head_sha": expected_head_sha or current_head,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_branch_sha,
|
||||
"worktree_path": resolved_worktree,
|
||||
"validation_commands": validation_commands,
|
||||
"validation_results": validation_results,
|
||||
"blocker_reason": blocker_reason,
|
||||
"saved_by_identity": _authenticated_username(h) or "",
|
||||
"saved_by_profile": profile.get("profile_name"),
|
||||
"remote": remote,
|
||||
"org": resolved_org,
|
||||
"repo": resolved_repo,
|
||||
}
|
||||
|
||||
try:
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
payload=payload,
|
||||
remote=remote,
|
||||
org=resolved_org,
|
||||
repo=resolved_repo,
|
||||
profile_identity=profile.get("profile_name"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"success": False, "reasons": [f"failed to save draft state: {str(exc)}"]}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"head_sha": payload["head_sha"],
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_branch_sha,
|
||||
"worktree_path": resolved_worktree,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_resume_review_draft(
|
||||
pr_number: int,
|
||||
submit: bool = False,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Resume a previously saved review draft for the given PR number.
|
||||
|
||||
Performs live-state checks (PR head SHA, target branch SHA, terminal lock, active leases,
|
||||
runtime/master parity, reviewer identity, worktree binding, validation freshness).
|
||||
Refuses to submit or mark ready if any state has changed unsafely.
|
||||
"""
|
||||
profile = get_profile()
|
||||
active_profile = profile.get("profile_name")
|
||||
|
||||
# 1. Load draft state
|
||||
try:
|
||||
draft = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=active_profile,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"success": False, "reasons": [f"failed to load draft state: {str(exc)}"]}
|
||||
|
||||
if not draft:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [f"no review draft found for PR #{pr_number} under profile '{active_profile}'"],
|
||||
}
|
||||
|
||||
if draft.get("pr_number") != pr_number:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [
|
||||
f"loaded draft is for PR #{draft.get('pr_number')}, but requested PR #{pr_number}"
|
||||
],
|
||||
}
|
||||
|
||||
# 2. Resolve remote context
|
||||
try:
|
||||
h, resolved_org, resolved_repo = _resolve(remote, host, org, repo)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "reasons": [str(exc)]}
|
||||
|
||||
auth = _auth(h)
|
||||
|
||||
# 3. Fetch live PR state
|
||||
try:
|
||||
pr_url = f"{repo_api_url(h, resolved_org, resolved_repo)}/pulls/{pr_number}"
|
||||
pr = api_request("GET", pr_url, auth) or {}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": [f"failed to fetch PR #{pr_number} from Gitea: {str(exc)}"],
|
||||
}
|
||||
|
||||
# 4. Perform live-state checks
|
||||
reasons = []
|
||||
|
||||
# PR must be open
|
||||
if pr.get("state") != "open":
|
||||
reasons.append(f"PR #{pr_number} is not open (state: '{pr.get('state')}')")
|
||||
|
||||
# Live head SHA must match saved head_sha
|
||||
live_head = (pr.get("head") or {}).get("sha")
|
||||
saved_head = draft.get("head_sha")
|
||||
if live_head != saved_head:
|
||||
reasons.append(
|
||||
f"PR head SHA changed (live={live_head!r}, saved={saved_head!r})"
|
||||
)
|
||||
|
||||
# Live target branch SHA must match saved target_branch_sha
|
||||
live_target_sha = (pr.get("base") or {}).get("sha")
|
||||
saved_target_sha = draft.get("target_branch_sha")
|
||||
if live_target_sha != saved_target_sha:
|
||||
reasons.append(
|
||||
f"target branch SHA changed (live={live_target_sha!r}, saved={saved_target_sha!r})"
|
||||
)
|
||||
|
||||
# Check #332 terminal lock
|
||||
hard_stop = terminal_review_hard_stop_reasons(pr_number, "resume")
|
||||
if hard_stop:
|
||||
reasons.extend(hard_stop)
|
||||
|
||||
# Check active reviewer lease (must be claimed by the current session)
|
||||
try:
|
||||
comments = _fetch_pr_comments(pr_number, remote=remote, host=host, org=resolved_org, repo=resolved_repo)
|
||||
except Exception as exc:
|
||||
reasons.append(f"failed to fetch PR comments: {str(exc)}")
|
||||
comments = []
|
||||
|
||||
active_lease = reviewer_pr_lease.find_active_reviewer_lease(comments, pr_number=pr_number)
|
||||
if not active_lease:
|
||||
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
|
||||
else:
|
||||
# Check that lease owner is our session_id
|
||||
session = reviewer_pr_lease.get_session_lease()
|
||||
session_id = (session or {}).get("session_id")
|
||||
owner_session_id = active_lease.get("session_id")
|
||||
if owner_session_id != session_id:
|
||||
reasons.append(
|
||||
f"active reviewer lease belongs to session_id={owner_session_id!r}, "
|
||||
f"but current session has session_id={session_id!r}"
|
||||
)
|
||||
|
||||
# Check runtime/master parity
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
config = gitea_config.load_config()
|
||||
matching_profiles = []
|
||||
if config and "profiles" in config:
|
||||
for p_name, p_data in config["profiles"].items():
|
||||
p_allowed = p_data.get("allowed_operations") or []
|
||||
p_forbidden = p_data.get("forbidden_operations") or []
|
||||
p_allowed_n = []
|
||||
for op in p_allowed:
|
||||
try:
|
||||
p_allowed_n.append(gitea_config.normalize_operation(op))
|
||||
except Exception:
|
||||
pass
|
||||
p_forbidden_n = []
|
||||
for op in p_forbidden:
|
||||
try:
|
||||
p_forbidden_n.append(gitea_config.normalize_operation(op))
|
||||
except Exception:
|
||||
pass
|
||||
# Check for "review_pr" or similar capability
|
||||
ok, _ = gitea_config.check_operation("gitea.pr.review", p_allowed_n, p_forbidden_n)
|
||||
if ok:
|
||||
matching_profiles.append(p_name)
|
||||
runtime_reasons = _check_mcp_runtimes_diagnostics("review_pr", matching_profiles)
|
||||
if runtime_reasons:
|
||||
reasons.extend(runtime_reasons)
|
||||
|
||||
# Check reviewer identity
|
||||
identity = _authenticated_username(h)
|
||||
if identity != draft.get("saved_by_identity"):
|
||||
reasons.append(
|
||||
f"reviewer identity mismatch (active={identity!r}, saved={draft.get('saved_by_identity')!r})"
|
||||
)
|
||||
|
||||
# Check worktree binding
|
||||
try:
|
||||
resolved_worktree = _resolve_preflight_workspace_path(worktree_path)
|
||||
except Exception as exc:
|
||||
reasons.append(f"failed to resolve current worktree: {str(exc)}")
|
||||
resolved_worktree = None
|
||||
if resolved_worktree != draft.get("worktree_path"):
|
||||
reasons.append(
|
||||
f"worktree binding mismatch (current={resolved_worktree!r}, saved={draft.get('worktree_path')!r})"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {"success": False, "reasons": reasons}
|
||||
|
||||
# 5. All checks pass! Mark decision lock as ready.
|
||||
action = draft.get("action")
|
||||
body = draft.get("body")
|
||||
saved_head = draft.get("head_sha")
|
||||
|
||||
lock = _load_review_decision_lock() or {}
|
||||
lock["final_review_decision_ready"] = True
|
||||
lock["ready_pr_number"] = pr_number
|
||||
lock["ready_action"] = action
|
||||
lock["ready_expected_head_sha"] = saved_head
|
||||
lock["ready_remote"] = remote
|
||||
lock["ready_org"] = resolved_org
|
||||
lock["ready_repo"] = resolved_repo
|
||||
_save_review_decision_lock(lock)
|
||||
|
||||
submitted = False
|
||||
submission_res = None
|
||||
if submit:
|
||||
# Submit the review!
|
||||
submission_res = gitea_submit_pr_review(
|
||||
pr_number=pr_number,
|
||||
action=action,
|
||||
body=body,
|
||||
expected_head_sha=saved_head,
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=resolved_org,
|
||||
repo=resolved_repo,
|
||||
final_review_decision_ready=True,
|
||||
worktree_path=resolved_worktree,
|
||||
)
|
||||
if submission_res.get("performed") or submission_res.get("success"):
|
||||
submitted = True
|
||||
# Delete the draft payload on success
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
payload=None,
|
||||
remote=remote,
|
||||
org=resolved_org,
|
||||
repo=resolved_repo,
|
||||
profile_identity=active_profile,
|
||||
)
|
||||
else:
|
||||
# If submit fails, return the submit failure reasons
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": submission_res.get("reasons") or ["submission failed"],
|
||||
"submission_result": submission_res,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"marked_ready": True,
|
||||
"submitted": submitted,
|
||||
"submission_result": submission_res,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_edit_pr(
|
||||
pr_number: int,
|
||||
@@ -6938,10 +6609,39 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _git_default_remote_name(root: str) -> str:
|
||||
"""First configured git remote name for *root*, defaulting to 'origin'.
|
||||
|
||||
Used to resolve the live remote master target for parity (#610). Best
|
||||
effort: any failure falls back to 'origin' so callers never raise.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "remote"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except Exception:
|
||||
return "origin"
|
||||
if res.returncode != 0:
|
||||
return "origin"
|
||||
names = [n.strip() for n in (res.stdout or "").splitlines() if n.strip()]
|
||||
return names[0] if names else "origin"
|
||||
|
||||
|
||||
def _current_master_parity() -> dict:
|
||||
"""Assess this process's code against the on-disk master HEAD (#420)."""
|
||||
"""Assess this process's code against local and live remote master (#420/#610).
|
||||
|
||||
Compares the daemon's startup commit, the on-disk checkout HEAD, and the
|
||||
live remote master target. A stale daemon relative to live master fails
|
||||
closed for mutations even when the local checkout HEAD still matches the
|
||||
startup commit. The live-remote read is best effort: an unresolved live
|
||||
head leaves read-only diagnostics unblocked but is never mutation-safe.
|
||||
"""
|
||||
current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
|
||||
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
|
||||
live_head = master_parity_gate.read_remote_master_head(
|
||||
PROJECT_ROOT, remote=_git_default_remote_name(PROJECT_ROOT))
|
||||
return master_parity_gate.assess_master_parity(
|
||||
_STARTUP_PARITY, current_head, live_remote_head=live_head)
|
||||
|
||||
|
||||
def _master_parity_block(op: str) -> list[str]:
|
||||
@@ -9271,15 +8971,34 @@ def gitea_get_runtime_context(
|
||||
"restart_required": parity["restart_required"],
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
# #610 distinguished mutation-safety signals:
|
||||
"daemon_start_head": parity["daemon_start_head"],
|
||||
"local_head": parity["local_head"],
|
||||
"live_remote_head": parity["live_remote_head"],
|
||||
"live_known": parity["live_known"],
|
||||
"live_stale": parity["live_stale"],
|
||||
"mutation_safe": parity["mutation_safe"],
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"mutation_gate_enforced": not master_parity_gate.gate_disabled(),
|
||||
# #610: the capability resolver is authoritative for mutation safety;
|
||||
# local parity alone must never authorize a mutation.
|
||||
"resolver_authoritative_for_mutation_safety": True,
|
||||
}
|
||||
if parity["stale"] and not master_parity_gate.gate_disabled():
|
||||
safe_next_action = (
|
||||
"Server code is stale relative to master; restart the Gitea MCP "
|
||||
"server to load current capability gates before mutating. "
|
||||
f"({master_parity_gate.format_parity(parity)})"
|
||||
)
|
||||
if parity["restart_required"] and not master_parity_gate.gate_disabled():
|
||||
if parity["live_stale"]:
|
||||
safe_next_action = (
|
||||
"Daemon is stale relative to LIVE remote master "
|
||||
f"(started {parity['startup_head'][:12] if parity['startup_head'] else 'unknown'}, "
|
||||
f"live master {parity['live_remote_head'][:12] if parity['live_remote_head'] else 'unknown'}); "
|
||||
"restart/reconnect the Gitea MCP server before mutating. The "
|
||||
"capability resolver is authoritative for mutation safety."
|
||||
)
|
||||
else:
|
||||
safe_next_action = (
|
||||
"Server code is stale relative to master; restart the Gitea MCP "
|
||||
"server to load current capability gates before mutating. "
|
||||
f"({master_parity_gate.format_parity(parity)})"
|
||||
)
|
||||
result["safe_next_action"] = safe_next_action
|
||||
|
||||
if reveal and h:
|
||||
@@ -9318,12 +9037,19 @@ def gitea_assess_master_parity(
|
||||
"determinable": parity["determinable"],
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
# #610 distinguished mutation-safety signals:
|
||||
"daemon_start_head": parity["daemon_start_head"],
|
||||
"local_head": parity["local_head"],
|
||||
"live_remote_head": parity["live_remote_head"],
|
||||
"live_known": parity["live_known"],
|
||||
"live_stale": parity["live_stale"],
|
||||
"mutation_safe": parity["mutation_safe"],
|
||||
"mutation_gate_enforced": enforced,
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"reasons": parity["reasons"],
|
||||
"process_root": PROJECT_ROOT,
|
||||
}
|
||||
if parity["stale"] and enforced:
|
||||
if parity["restart_required"] and enforced:
|
||||
out["report"] = master_parity_gate.parity_report(parity)
|
||||
return out
|
||||
def gitea_record_pre_review_command(
|
||||
|
||||
+171
-17
@@ -24,12 +24,26 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Live-remote head cache: the parity gate runs on every mutation and every
|
||||
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
|
||||
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
|
||||
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
|
||||
_REMOTE_HEAD_TTL = 60.0
|
||||
|
||||
|
||||
def _clear_remote_head_cache() -> None:
|
||||
"""Reset the live-remote head cache (test isolation / forced refresh)."""
|
||||
_REMOTE_HEAD_CACHE.clear()
|
||||
|
||||
# Environment escape hatches (ops + tests):
|
||||
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
|
||||
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
|
||||
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
|
||||
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
|
||||
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
|
||||
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_HEAD"
|
||||
|
||||
|
||||
def read_git_head(root: str) -> str | None:
|
||||
@@ -58,6 +72,57 @@ def read_git_head(root: str) -> str | None:
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
def read_remote_master_head(
|
||||
root: str,
|
||||
remote: str = "origin",
|
||||
branch: str = "master",
|
||||
ttl: float = _REMOTE_HEAD_TTL,
|
||||
) -> str | None:
|
||||
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
|
||||
|
||||
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
|
||||
a daemon that is behind the live remote master apart from one whose local
|
||||
checkout simply hasn't been pulled. ``None`` means the live head could not
|
||||
be resolved (offline, no such remote, git unavailable, error) -- callers
|
||||
must treat unknown live state as *not mutation-safe* while never blocking
|
||||
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
|
||||
precedence so the wiring can be exercised deterministically and offline.
|
||||
|
||||
The result is cached for *ttl* seconds per (root, remote, branch) so the
|
||||
gate does not run a network probe on every mutation/read (``ttl=0`` forces
|
||||
a live probe). Both hits and ``None`` misses are cached to bound offline
|
||||
latency; the env override bypasses the cache and the subprocess entirely.
|
||||
"""
|
||||
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
|
||||
if forced is not None:
|
||||
return forced.strip() or None
|
||||
if not root:
|
||||
return None
|
||||
key = (root, remote, branch)
|
||||
now = time.monotonic()
|
||||
if ttl > 0:
|
||||
cached = _REMOTE_HEAD_CACHE.get(key)
|
||||
if cached is not None and (now - cached[0]) < ttl:
|
||||
return cached[1]
|
||||
sha: str | None = None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
lines = (res.stdout or "").strip().splitlines()
|
||||
if lines:
|
||||
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
|
||||
except Exception:
|
||||
sha = None
|
||||
_REMOTE_HEAD_CACHE[key] = (now, sha)
|
||||
return sha
|
||||
|
||||
|
||||
def capture_startup_parity(root: str, head: str | None = None) -> dict:
|
||||
"""Capture the process source-tree baseline once at server startup.
|
||||
|
||||
@@ -72,18 +137,38 @@ def _short(sha: str | None) -> str:
|
||||
return sha[:12] if sha else "unknown"
|
||||
|
||||
|
||||
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
||||
def assess_master_parity(
|
||||
startup: dict | None,
|
||||
current_head: str | None,
|
||||
live_remote_head: str | None = None,
|
||||
) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
||||
Pure: all HEADs are supplied by the caller. Returns a structured result:
|
||||
|
||||
- ``in_parity`` -- server code matches the on-disk master (or parity
|
||||
could not be determined, which is not treated as stale).
|
||||
- ``stale`` -- the on-disk master has definitively advanced past the
|
||||
running process.
|
||||
- ``restart_required`` -- alias of ``stale``; the recovery action.
|
||||
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
||||
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
|
||||
- ``determinable`` -- whether both local HEADs were known well enough to
|
||||
compare.
|
||||
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||
|
||||
#610 adds live-remote awareness so a daemon that is stale relative to the
|
||||
*live* remote master cannot report a mutation-safe result even when the
|
||||
local checkout HEAD still matches the daemon's startup commit:
|
||||
|
||||
- ``daemon_start_head`` -- the commit the running process started at
|
||||
(alias of ``startup_head``, named for clarity in reports).
|
||||
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
|
||||
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
|
||||
could not be fetched.
|
||||
- ``live_known`` -- whether the live remote target was resolved.
|
||||
- ``live_stale`` -- the live remote master has advanced past the running
|
||||
process (daemon is behind live master) even if local parity is green.
|
||||
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
|
||||
target all agree; the only state in which a mutation may rely on parity.
|
||||
"""
|
||||
startup_head = (startup or {}).get("startup_head")
|
||||
reasons: list[str] = []
|
||||
@@ -91,32 +176,56 @@ def assess_master_parity(startup: dict | None, current_head: str | None) -> dict
|
||||
if startup_head is None:
|
||||
reasons.append(
|
||||
"startup commit was not captured; code parity cannot be enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
if current_head is None:
|
||||
reasons.append(
|
||||
"current workspace HEAD could not be read; code parity cannot be "
|
||||
"enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
|
||||
if startup_head == current_head:
|
||||
return _result(True, False, True, startup_head, current_head, reasons)
|
||||
local_in_parity = startup_head == current_head
|
||||
local_stale = not local_in_parity
|
||||
if local_stale:
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the "
|
||||
f"workspace master is now {_short(current_head)}; restart the "
|
||||
f"server to load the current capability gates")
|
||||
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the workspace "
|
||||
f"master is now {_short(current_head)}; restart the server to load the "
|
||||
f"current capability gates")
|
||||
return _result(False, True, True, startup_head, current_head, reasons)
|
||||
live_known = live_remote_head is not None
|
||||
live_stale = live_known and live_remote_head != startup_head
|
||||
if live_stale:
|
||||
reasons.append(
|
||||
f"live remote master is {_short(live_remote_head)} but the MCP "
|
||||
f"server started at {_short(startup_head)}; the daemon is stale "
|
||||
f"relative to live master -- restart/reconnect before mutating")
|
||||
|
||||
return _result(
|
||||
local_in_parity, local_stale, True, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons):
|
||||
live_known = live_remote_head is not None
|
||||
mutation_safe = (
|
||||
determinable and in_parity and live_known and not live_stale)
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale,
|
||||
"restart_required": stale or live_stale,
|
||||
"determinable": determinable,
|
||||
"startup_head": startup_head,
|
||||
"current_head": current_head,
|
||||
# #610 distinguished signals:
|
||||
"daemon_start_head": startup_head,
|
||||
"local_head": current_head,
|
||||
"live_remote_head": live_remote_head,
|
||||
"live_known": live_known,
|
||||
"live_stale": live_stale,
|
||||
"mutation_safe": mutation_safe,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
@@ -130,11 +239,13 @@ def parity_block_reasons(assessment: dict) -> list[str]:
|
||||
"""Block reasons for a mutation gate (empty when the mutation may proceed).
|
||||
|
||||
A disabled gate or an in-parity / non-determinable assessment yields no
|
||||
reasons; only a definitively stale server blocks.
|
||||
reasons. A definitively stale server blocks, and (#610) a daemon that is
|
||||
stale relative to the *live* remote master blocks even when the local
|
||||
checkout HEAD still matches the daemon's startup commit.
|
||||
"""
|
||||
if gate_disabled():
|
||||
return []
|
||||
if assessment.get("stale"):
|
||||
if assessment.get("stale") or assessment.get("live_stale"):
|
||||
return list(assessment.get("reasons") or
|
||||
["server code is stale relative to master (fail closed)"])
|
||||
return []
|
||||
@@ -147,6 +258,10 @@ def parity_report(assessment: dict) -> dict:
|
||||
"restart_required": True,
|
||||
"startup_head": assessment.get("startup_head"),
|
||||
"current_head": assessment.get("current_head"),
|
||||
# #610: name the live remote target so the report distinguishes a
|
||||
# local-code stale from a daemon-behind-live-master stale.
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"live_stale": bool(assessment.get("live_stale")),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"recovery": [
|
||||
"The running MCP server is executing code older than the current "
|
||||
@@ -157,6 +272,45 @@ def parity_report(assessment: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def parity_resolver_disagreement(
|
||||
assessment: dict,
|
||||
resolver_restart_required: bool,
|
||||
) -> dict | None:
|
||||
"""Typed blocker when the resolver requires restart but parity looks green.
|
||||
|
||||
The capability resolver (``gitea_resolve_task_capability``) detects stale
|
||||
runtime authoritatively for mutation safety (#610). When it requires a
|
||||
restart, local-only parity must never override it: this returns a typed,
|
||||
fail-closed blocker that names the resolver as authoritative. Returns
|
||||
``None`` when the resolver does not require a restart.
|
||||
"""
|
||||
if not resolver_restart_required:
|
||||
return None
|
||||
parity_optimistic = bool(assessment.get("in_parity")) and not (
|
||||
assessment.get("stale") or assessment.get("live_stale"))
|
||||
return {
|
||||
"kind": "parity_resolver_disagreement",
|
||||
"restart_required": True,
|
||||
"resolver_authoritative": True,
|
||||
"parity_optimistic": parity_optimistic,
|
||||
"daemon_start_head": assessment.get("daemon_start_head"),
|
||||
"local_head": assessment.get("local_head"),
|
||||
"live_remote_head": assessment.get("live_remote_head"),
|
||||
"reasons": [
|
||||
"The capability resolver requires a restart/reconnect (stale "
|
||||
"runtime) but master-parity reported local code as in-parity. "
|
||||
"The resolver is authoritative for mutation safety; do not mutate "
|
||||
"on local parity alone. Restart/reconnect the Gitea MCP server "
|
||||
"and re-verify before mutating.",
|
||||
],
|
||||
"recovery": [
|
||||
"Trust the resolver: treat this session as stale.",
|
||||
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
|
||||
"current master and live target state, then re-run preflight.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_parity(assessment: dict) -> str:
|
||||
"""One-line human summary for logs / runtime context."""
|
||||
if assessment.get("stale"):
|
||||
|
||||
@@ -30,8 +30,6 @@ DEFAULT_TTL_HOURS = 4.0
|
||||
|
||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||
KIND_DECISION_LOCK = "review_decision_lock"
|
||||
KIND_REVIEW_DRAFT = "review_draft"
|
||||
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||
|
||||
@@ -78,6 +78,95 @@ class TestBlockReasonsAndReport(unittest.TestCase):
|
||||
self.assertTrue(report["recovery"])
|
||||
|
||||
|
||||
class TestLiveRemoteParity(unittest.TestCase):
|
||||
"""#610: parity must account for the live remote master, not just local.
|
||||
|
||||
The daemon can be stale relative to the live remote target while the local
|
||||
checkout HEAD still matches the daemon's startup commit, so local parity
|
||||
reports green even though a mutation would run against outdated code.
|
||||
"""
|
||||
|
||||
SHA_C = "c" * 40
|
||||
|
||||
def test_distinguishes_three_shas(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertEqual(res["daemon_start_head"], SHA_A)
|
||||
self.assertEqual(res["local_head"], SHA_A)
|
||||
self.assertEqual(res["live_remote_head"], SHA_B)
|
||||
|
||||
def test_mutation_safe_only_when_all_three_match(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
|
||||
self.assertTrue(res["mutation_safe"])
|
||||
self.assertTrue(res["live_known"])
|
||||
self.assertFalse(res["live_stale"])
|
||||
|
||||
def test_live_stale_when_remote_advanced_past_daemon(self):
|
||||
# Local checkout still matches the daemon start (local parity green),
|
||||
# but the live remote master has advanced -> daemon is live-stale.
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertTrue(res["in_parity"]) # local parity still green
|
||||
self.assertTrue(res["live_stale"])
|
||||
self.assertFalse(res["mutation_safe"])
|
||||
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
|
||||
|
||||
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
|
||||
# Non-goal: unfetchable live remote must not be treated as stale for
|
||||
# read-only, but a mutation-safe claim fails closed.
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
|
||||
self.assertFalse(res["live_known"])
|
||||
self.assertFalse(res["mutation_safe"])
|
||||
self.assertFalse(res["live_stale"])
|
||||
self.assertTrue(res["in_parity"])
|
||||
|
||||
def test_default_live_remote_preserves_legacy_shape(self):
|
||||
# Callers that do not supply a live head keep the pre-#610 behavior:
|
||||
# in-parity, not live-stale, no live-derived block.
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertFalse(res["live_stale"])
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
|
||||
class TestLiveStaleBlockAndReport(unittest.TestCase):
|
||||
"""#610: live-staleness must block mutations and surface a typed blocker."""
|
||||
|
||||
def test_live_stale_produces_block_reasons(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
self.assertTrue(mp.parity_block_reasons(res))
|
||||
|
||||
def test_disable_env_suppresses_live_stale_block(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
def test_resolver_disagreement_returns_typed_blocker(self):
|
||||
# Parity says local-green, resolver says restart required -> disagreement
|
||||
# is a typed, fail-closed blocker naming the resolver as authoritative.
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
|
||||
self.assertIsNotNone(blocker)
|
||||
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
|
||||
self.assertTrue(blocker["restart_required"])
|
||||
self.assertTrue(blocker["resolver_authoritative"])
|
||||
|
||||
def test_no_disagreement_when_resolver_agrees(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertIsNone(
|
||||
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
|
||||
|
||||
def test_live_stale_report_names_live_remote(self):
|
||||
res = mp.assess_master_parity(
|
||||
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
|
||||
report = mp.parity_report(res)
|
||||
self.assertEqual(report["live_remote_head"], SHA_B)
|
||||
self.assertTrue(report["restart_required"])
|
||||
|
||||
|
||||
class TestReadGitHead(unittest.TestCase):
|
||||
def test_test_override_takes_precedence(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||
@@ -95,6 +184,78 @@ class TestReadGitHead(unittest.TestCase):
|
||||
self.assertIsNone(mp.read_git_head(""))
|
||||
|
||||
|
||||
class TestReadRemoteMasterHead(unittest.TestCase):
|
||||
"""#610: live remote master head reader (env-overridable, fails to None)."""
|
||||
|
||||
def test_test_override_takes_precedence(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
|
||||
|
||||
def test_blank_override_is_none(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
|
||||
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
|
||||
|
||||
def test_unfetchable_remote_is_none(self):
|
||||
# No override; a bogus root/remote must fail closed to None, never raise.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertIsNone(
|
||||
mp.read_remote_master_head("/nonexistent", remote="nope"))
|
||||
|
||||
|
||||
class TestRemoteHeadCache(unittest.TestCase):
|
||||
"""#610: live remote reads are cached with a TTL to stay off the network.
|
||||
|
||||
The parity gate runs on every mutation and every runtime-context read, so an
|
||||
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
mp._clear_remote_head_cache()
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
|
||||
self._env = patch.dict(os.environ, env, clear=True)
|
||||
self._env.start()
|
||||
self.addCleanup(self._env.stop)
|
||||
self.addCleanup(mp._clear_remote_head_cache)
|
||||
|
||||
def _fake_run(self, sha):
|
||||
class _R:
|
||||
returncode = 0
|
||||
stdout = f"{sha}\trefs/heads/master\n"
|
||||
calls = {"n": 0}
|
||||
|
||||
def run(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return _R()
|
||||
return run, calls
|
||||
|
||||
def test_second_call_within_ttl_uses_cache(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
||||
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
|
||||
self.assertEqual(a, SHA_B)
|
||||
self.assertEqual(b, SHA_B)
|
||||
self.assertEqual(calls["n"], 1)
|
||||
|
||||
def test_zero_ttl_bypasses_cache(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
||||
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
|
||||
self.assertEqual(calls["n"], 2)
|
||||
|
||||
def test_env_override_never_touches_subprocess(self):
|
||||
run, calls = self._fake_run(SHA_B)
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
||||
with patch.object(mp.subprocess, "run", run):
|
||||
self.assertEqual(
|
||||
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
|
||||
self.assertEqual(calls["n"], 0)
|
||||
|
||||
|
||||
class TestServerWiring(unittest.TestCase):
|
||||
"""Integration with the gate choke point in the server namespace."""
|
||||
|
||||
@@ -105,6 +266,13 @@ class TestServerWiring(unittest.TestCase):
|
||||
self._saved = self.srv._STARTUP_PARITY
|
||||
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
|
||||
"startup_head": SHA_A}
|
||||
# Keep the live-remote read hermetic (no real ls-remote network call):
|
||||
# default the live master to the daemon start so parity is fully green
|
||||
# unless a test overrides the live head explicitly (#610).
|
||||
self._live_patch = patch.dict(
|
||||
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
|
||||
self._live_patch.start()
|
||||
self.addCleanup(self._live_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self.srv._STARTUP_PARITY = self._saved
|
||||
@@ -147,6 +315,36 @@ class TestServerWiring(unittest.TestCase):
|
||||
self.assertTrue(out["in_parity"])
|
||||
self.assertNotIn("report", out)
|
||||
|
||||
# --- #610: live-remote wiring -------------------------------------------
|
||||
|
||||
def test_live_stale_blocks_mutation_though_local_green(self):
|
||||
# Local checkout matches the daemon start (local parity green) but the
|
||||
# live remote master has advanced -> mutations must fail closed.
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
||||
self.assertTrue(
|
||||
self.srv._master_parity_block("gitea.pr.create"))
|
||||
|
||||
def test_assess_tool_exposes_three_distinct_shas(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertEqual(out["daemon_start_head"], SHA_A)
|
||||
self.assertEqual(out["local_head"], SHA_A)
|
||||
self.assertEqual(out["live_remote_head"], SHA_B)
|
||||
self.assertTrue(out["live_stale"])
|
||||
self.assertFalse(out["mutation_safe"])
|
||||
self.assertIn("report", out)
|
||||
|
||||
def test_assess_tool_mutation_safe_when_all_three_match(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
|
||||
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertTrue(out["mutation_safe"])
|
||||
self.assertFalse(out["live_stale"])
|
||||
self.assertNotIn("report", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
"""Tests for preserving and resuming prepared Gitea review drafts (#609)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys_path_root = str(Path(__file__).resolve().parent.parent)
|
||||
if sys_path_root not in sys.path:
|
||||
sys.path.insert(0, sys_path_root)
|
||||
|
||||
import mcp_session_state
|
||||
import gitea_mcp_server
|
||||
import reviewer_pr_lease
|
||||
|
||||
|
||||
class TestReviewDrafts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self.state_dir = self._tmpdir.name
|
||||
self._env = patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
mcp_session_state.STATE_DIR_ENV: self.state_dir,
|
||||
mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
||||
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||
"PYTEST_CURRENT_TEST": "1", # skip runtime stale checks
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
self._env.start()
|
||||
|
||||
# Mock workspace binding to pass in test context
|
||||
self._nwb_patch = patch(
|
||||
"gitea_mcp_server.nwb.assess_namespace_mutation_workspace",
|
||||
return_value={"block": False, "mutation_workspace": "/tmp/test-worktree"},
|
||||
)
|
||||
self._nwb_patch.start()
|
||||
|
||||
# Mock authentication headers
|
||||
self._auth_patch = patch(
|
||||
"gitea_mcp_server.get_auth_header",
|
||||
return_value="Bearer mock-token",
|
||||
)
|
||||
self._auth_patch.start()
|
||||
|
||||
self._username_patch = patch(
|
||||
"gitea_mcp_server._authenticated_username",
|
||||
return_value="sysadmin",
|
||||
)
|
||||
self._username_patch.start()
|
||||
|
||||
# Clear/Mock local session lease in-memory state
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
|
||||
def tearDown(self):
|
||||
self._username_patch.stop()
|
||||
self._auth_patch.stop()
|
||||
self._nwb_patch.stop()
|
||||
self._env.stop()
|
||||
self._tmpdir.cleanup()
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_save_draft_success(self, mock_api):
|
||||
mock_api.return_value = {
|
||||
"head": {"sha": "headsha1234567890"},
|
||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||
}
|
||||
res = gitea_mcp_server.gitea_save_review_draft(
|
||||
pr_number=587,
|
||||
action="approve",
|
||||
body="Good changes",
|
||||
expected_head_sha="headsha1234567890",
|
||||
validation_commands="pytest tests/",
|
||||
validation_results="all pass",
|
||||
blocker_reason="stale daemon",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/test-worktree",
|
||||
)
|
||||
self.assertTrue(res.get("success"))
|
||||
self.assertEqual(res.get("head_sha"), "headsha1234567890")
|
||||
|
||||
# Load draft and verify fields
|
||||
draft = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertIsNotNone(draft)
|
||||
self.assertEqual(draft.get("pr_number"), 587)
|
||||
self.assertEqual(draft.get("action"), "approve")
|
||||
self.assertEqual(draft.get("body"), "Good changes")
|
||||
self.assertEqual(draft.get("validation_commands"), "pytest tests/")
|
||||
self.assertEqual(draft.get("validation_results"), "all pass")
|
||||
self.assertEqual(draft.get("blocker_reason"), "stale daemon")
|
||||
self.assertEqual(os.path.realpath(draft.get("worktree_path")), os.path.realpath("/tmp/test-worktree"))
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||
@patch("gitea_mcp_server.gitea_submit_pr_review")
|
||||
def test_resume_draft_and_submit_success(self, mock_submit, mock_comments, mock_api):
|
||||
# 1. Save draft
|
||||
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",
|
||||
)
|
||||
|
||||
# Seed local session lease
|
||||
reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": 587,
|
||||
"session_id": "session-1234",
|
||||
})
|
||||
|
||||
# Mock Gitea comments to return active reviewer lease owned by us
|
||||
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_submit.return_value = {"performed": True, "success": True}
|
||||
|
||||
# 2. Resume draft
|
||||
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||
pr_number=587,
|
||||
submit=True,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/test-worktree",
|
||||
)
|
||||
|
||||
self.assertTrue(res.get("success"))
|
||||
self.assertTrue(res.get("marked_ready"))
|
||||
self.assertTrue(res.get("submitted"))
|
||||
mock_submit.assert_called_once()
|
||||
|
||||
# Verify draft was deleted after successful submit
|
||||
draft = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_REVIEW_DRAFT,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
profile_identity="prgs-reviewer",
|
||||
)
|
||||
self.assertIsNone(draft)
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||
def test_resume_draft_fails_checks(self, mock_comments, mock_api):
|
||||
# Save a draft
|
||||
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",
|
||||
)
|
||||
|
||||
# Seed local session lease
|
||||
reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": 587,
|
||||
"session_id": "session-1234",
|
||||
})
|
||||
|
||||
# Mock Gitea comments to return active reviewer lease owned by us
|
||||
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"
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
# Case A: PR closed
|
||||
mock_api.return_value = {
|
||||
"state": "closed",
|
||||
"head": {"sha": "headsha1234567890"},
|
||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||
}
|
||||
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.assertIn("PR #587 is not open", res.get("reasons")[0])
|
||||
|
||||
# Case B: Head changed
|
||||
mock_api.return_value = {
|
||||
"state": "open",
|
||||
"head": {"sha": "headsha_NEW"},
|
||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||
}
|
||||
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.assertIn("PR head SHA changed", res.get("reasons")[0])
|
||||
|
||||
# Case C: Target branch changed
|
||||
mock_api.return_value = {
|
||||
"state": "open",
|
||||
"head": {"sha": "headsha1234567890"},
|
||||
"base": {"ref": "master", "sha": "basesha_NEW"},
|
||||
}
|
||||
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.assertIn("target branch SHA changed", res.get("reasons")[0])
|
||||
|
||||
# Case D: Worktree mismatch
|
||||
mock_api.return_value = {
|
||||
"state": "open",
|
||||
"head": {"sha": "headsha1234567890"},
|
||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||
}
|
||||
res = gitea_mcp_server.gitea_resume_review_draft(
|
||||
pr_number=587,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/different-worktree",
|
||||
)
|
||||
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 [])))
|
||||
|
||||
Reference in New Issue
Block a user