Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34ec86b90 | ||
|
|
c780ded653 |
@@ -1,807 +0,0 @@
|
||||
"""Common anti-stomp preflight for every MCP mutation tool (#604).
|
||||
|
||||
Even with leases, mutation tools need a **shared** preflight that prevents
|
||||
stale sessions, wrong worktrees, wrong repos, old prompts, terminal locks,
|
||||
foreign leases, contaminated approvals, and root-checkout mutations from
|
||||
slipping through.
|
||||
|
||||
This module is the pure assessment core. Callers (MCP mutation entrypoints)
|
||||
gather live facts and pass them in — nothing here performs git, network, or
|
||||
durable-state I/O. Existing happy-path guards remain authoritative; this
|
||||
module composes them into one typed, fail-closed result.
|
||||
|
||||
Required checks (issue #604):
|
||||
|
||||
* repo/org verification
|
||||
* profile/role verification
|
||||
* root checkout clean and not used for mutation (when role requires branches/)
|
||||
* worktree under branches when required
|
||||
* active lease ownership (when lease is required for the mutation)
|
||||
* terminal lock status (when applicable)
|
||||
* expected head SHA (when pinned)
|
||||
* stale runtime status
|
||||
* workflow hash (when a workflow load is required)
|
||||
* source contamination status
|
||||
* no manual state/mtime/source-file bypass
|
||||
|
||||
Failure returns a typed blocker and an exact next action. There is **no**
|
||||
agent-facing bypass flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import author_mutation_worktree
|
||||
import master_parity_gate
|
||||
import remote_repo_guard
|
||||
import root_checkout_guard
|
||||
import stable_branch_push_guard
|
||||
|
||||
# ── public constants ──────────────────────────────────────────────────────────
|
||||
|
||||
# Typed blocker kinds returned in the structured result.
|
||||
BLOCKER_WRONG_REPO = "wrong_repo"
|
||||
BLOCKER_WRONG_ROLE = "wrong_role"
|
||||
BLOCKER_ROOT_CHECKOUT = "root_checkout_mutation"
|
||||
BLOCKER_WRONG_WORKTREE = "wrong_worktree"
|
||||
BLOCKER_FOREIGN_LEASE = "foreign_lease"
|
||||
BLOCKER_TERMINAL_LOCK = "terminal_lock"
|
||||
BLOCKER_HEAD_SHA = "head_sha_mismatch"
|
||||
BLOCKER_STALE_RUNTIME = "stale_runtime"
|
||||
BLOCKER_WORKFLOW_HASH = "workflow_hash"
|
||||
BLOCKER_SOURCE_CONTAMINATION = "source_contamination"
|
||||
BLOCKER_MANUAL_BYPASS = "manual_bypass"
|
||||
|
||||
BLOCKER_KINDS = frozenset({
|
||||
BLOCKER_WRONG_REPO,
|
||||
BLOCKER_WRONG_ROLE,
|
||||
BLOCKER_ROOT_CHECKOUT,
|
||||
BLOCKER_WRONG_WORKTREE,
|
||||
BLOCKER_FOREIGN_LEASE,
|
||||
BLOCKER_TERMINAL_LOCK,
|
||||
BLOCKER_HEAD_SHA,
|
||||
BLOCKER_STALE_RUNTIME,
|
||||
BLOCKER_WORKFLOW_HASH,
|
||||
BLOCKER_SOURCE_CONTAMINATION,
|
||||
BLOCKER_MANUAL_BYPASS,
|
||||
})
|
||||
|
||||
# Mutation tasks that must invoke the shared anti-stomp preflight before
|
||||
# acting (issue #604 AC1). Every member must appear as a live task= kwarg
|
||||
# to verify_preflight_purity / _run_anti_stomp_preflight (or the review
|
||||
# anti_task map) in gitea_mcp_server.py. Inventory↔wiring tests fail if
|
||||
# a declared task is not wired.
|
||||
MUTATION_TASKS = frozenset({
|
||||
"create_issue",
|
||||
"comment_issue",
|
||||
"close_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"set_issue_labels",
|
||||
"create_label",
|
||||
"create_pr",
|
||||
"close_pr",
|
||||
"edit_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"cleanup_stale_claims",
|
||||
"reconcile_merged_cleanups",
|
||||
"reconcile_already_landed_pr",
|
||||
"reconcile_close_superseded_pr",
|
||||
"post_heartbeat",
|
||||
"acquire_reviewer_pr_lease",
|
||||
"gitea_acquire_reviewer_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"merge_pr",
|
||||
})
|
||||
|
||||
# Intentionally excluded from MUTATION_TASKS. Each has a dedicated
|
||||
# fail-closed gate that preserves decision-lock ownership, workflow-hash,
|
||||
# head-SHA, and repository consistency. Do not re-add without either
|
||||
# wiring shared preflight or updating this rationale (#604 Blocker B).
|
||||
DEDICATED_GATE_MUTATIONS: dict[str, str] = {
|
||||
"mark_final_review_decision": (
|
||||
"Local review-decision lock only. Enforced by session lock ownership, "
|
||||
"workflow-hash, expected head SHA, PR work lease, eligibility, and "
|
||||
"terminal-lock gates. No Gitea review POST; shared anti-stomp would "
|
||||
"duplicate without side-effect-ordering value."
|
||||
),
|
||||
"save_review_draft": (
|
||||
"Local session-state draft save with live PR head/base consistency "
|
||||
"checks. Not a Gitea review/merge mutation; dedicated head-SHA and "
|
||||
"worktree resolution gates apply."
|
||||
),
|
||||
"resume_review_draft": (
|
||||
"Live-state resume with terminal lock, lease ownership, head/base SHA, "
|
||||
"and parity checks. When submit=True, delegates to gitea_submit_pr_review "
|
||||
"which runs shared anti-stomp before the Gitea review POST."
|
||||
),
|
||||
"cleanup_stale_review_decision_lock": (
|
||||
"Moot-lock cleanup with identity match, live PR merged/closed proof, "
|
||||
"and reviewer capability gate (#594). Distinct from shared anti-stomp."
|
||||
),
|
||||
"gitea_cleanup_stale_review_decision_lock": (
|
||||
"Alias of cleanup_stale_review_decision_lock; dedicated #594 path."
|
||||
),
|
||||
"heartbeat_reviewer_pr_lease": (
|
||||
"Lease heartbeat posts via verify_preflight_purity(task='review_pr') "
|
||||
"plus in-session lease ownership checks; not a separate mutation class."
|
||||
),
|
||||
"release_reviewer_pr_lease": (
|
||||
"Lease release uses workspace binding + ownership checks; posts only "
|
||||
"when the session owns the active lease."
|
||||
),
|
||||
}
|
||||
|
||||
# Roles that must mutate from a branches/ worktree (not the control checkout).
|
||||
_BRANCHES_REQUIRED_ROLES = frozenset({"author"})
|
||||
|
||||
# Reviewer/merger mutations that require an owned lease + head pinning when
|
||||
# the caller supplies those facts.
|
||||
_LEASE_AWARE_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"merge_pr",
|
||||
"acquire_reviewer_pr_lease",
|
||||
"gitea_acquire_reviewer_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
})
|
||||
|
||||
_NEXT_ACTIONS: dict[str, str] = {
|
||||
BLOCKER_WRONG_REPO: (
|
||||
"Pass explicit org= and repo= matching the local git remote "
|
||||
"(e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools) and retry."
|
||||
),
|
||||
BLOCKER_WRONG_ROLE: (
|
||||
"Call gitea_resolve_task_capability, switch to the required "
|
||||
"namespace/profile, re-verify with gitea_whoami, then retry."
|
||||
),
|
||||
BLOCKER_ROOT_CHECKOUT: (
|
||||
"Leave the root control checkout on clean master; create or switch "
|
||||
"to a session-owned worktree under branches/ and retry the mutation "
|
||||
"with worktree_path set."
|
||||
),
|
||||
BLOCKER_WRONG_WORKTREE: (
|
||||
"Create or bind a session-owned worktree under branches/, set "
|
||||
"worktree_path (or GITEA_ACTIVE_WORKTREE), and retry."
|
||||
),
|
||||
BLOCKER_FOREIGN_LEASE: (
|
||||
"Stop. Do not stomp a foreign lease. Wait for expiry, request "
|
||||
"takeover through the sanctioned path, or hand off to the owner."
|
||||
),
|
||||
BLOCKER_TERMINAL_LOCK: (
|
||||
"Stop. A terminal review-decision lock is active for this head. "
|
||||
"Do not re-approve/merge; follow the #332/#620 recovery path."
|
||||
),
|
||||
BLOCKER_HEAD_SHA: (
|
||||
"Re-fetch the live PR head with gitea_view_pr, re-pin "
|
||||
"expected_head_sha to the current head, re-validate, then retry."
|
||||
),
|
||||
BLOCKER_STALE_RUNTIME: (
|
||||
"Restart the Gitea MCP server so it reloads master's capability "
|
||||
"gates, re-run gitea_whoami + gitea_resolve_task_capability, then retry."
|
||||
),
|
||||
BLOCKER_WORKFLOW_HASH: (
|
||||
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||
"rerun the full review-merge workflow from inventory (no approve/merge replay)."
|
||||
),
|
||||
BLOCKER_SOURCE_CONTAMINATION: (
|
||||
"Stop. Session is source-contaminated. Hand off to a reconciler for "
|
||||
"audit/clear; do not clear markers by deleting session-state files."
|
||||
),
|
||||
BLOCKER_MANUAL_BYPASS: (
|
||||
"Stop. Manual state/mtime/source-file bypass is forbidden. Use "
|
||||
"sanctioned MCP tools only; never delete or rewrite session-state "
|
||||
"or lock files by hand."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def is_mutation_task(task: str | None) -> bool:
|
||||
"""True when *task* is in the #604 mutation set (normalized)."""
|
||||
name = (task or "").strip().lower().removeprefix("gitea_")
|
||||
if not name:
|
||||
return False
|
||||
if name in MUTATION_TASKS:
|
||||
return True
|
||||
# Accept gitea_ prefixed membership for aliases already in the set.
|
||||
return f"gitea_{name}" in MUTATION_TASKS
|
||||
|
||||
|
||||
def roles_compatible(active_role: str | None, required_role: str | None) -> bool:
|
||||
"""Whether *active_role* may perform a task that maps to *required_role*.
|
||||
|
||||
Exact match always passes. Reconcilers may run author-class mutations
|
||||
(comment/close/cleanup) that the capability map stamps as ``author`` —
|
||||
matching the long-standing reconciler exemption for control-checkout
|
||||
work. No other cross-role substitutions are allowed.
|
||||
|
||||
Capability-authorized multi-role tasks (e.g. reviewer holding
|
||||
``gitea.issue.comment`` for ``comment_issue``) are handled by
|
||||
:func:`authorization_compatible`, not by expanding this role matrix.
|
||||
"""
|
||||
active = (active_role or "").strip().lower()
|
||||
required = (required_role or "").strip().lower()
|
||||
if not active or not required:
|
||||
return True
|
||||
if active == required:
|
||||
return True
|
||||
if active == "reconciler" and required in {"author", "reconciler"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def authorization_compatible(
|
||||
active_role: str | None,
|
||||
required_role: str | None,
|
||||
*,
|
||||
required_permission: str | None = None,
|
||||
allowed_operations: Any = None,
|
||||
) -> bool:
|
||||
"""Whether the active session may run a task given role *and* capability.
|
||||
|
||||
Order:
|
||||
|
||||
1. :func:`roles_compatible` (exact role or narrow reconciler→author).
|
||||
2. Capability possession: when *required_permission* is non-empty and
|
||||
present in *allowed_operations*, allow even if the nominal task role
|
||||
differs (e.g. reviewer/merger with ``gitea.issue.comment`` on
|
||||
``comment_issue`` / ``mark_issue`` / ``set_issue_labels`` /
|
||||
``lock_issue``).
|
||||
|
||||
Fail closed otherwise. This is **not** a broad reviewer→author or
|
||||
merger→author role rewrite: without the specific permission the check
|
||||
still denies. Missing *allowed_operations* when a permission is required
|
||||
also fails closed (callers must supply the live profile op list).
|
||||
"""
|
||||
if roles_compatible(active_role, required_role):
|
||||
return True
|
||||
perm = (required_permission or "").strip()
|
||||
if not perm:
|
||||
return False
|
||||
if allowed_operations is None:
|
||||
return False
|
||||
try:
|
||||
ops = {str(o).strip() for o in allowed_operations if o is not None}
|
||||
except TypeError:
|
||||
return False
|
||||
return perm in ops
|
||||
|
||||
|
||||
def _blocker(
|
||||
kind: str,
|
||||
reasons: list[str],
|
||||
*,
|
||||
exact_next_action: str | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if kind not in BLOCKER_KINDS:
|
||||
raise ValueError(f"unknown anti-stomp blocker kind: {kind!r}")
|
||||
return {
|
||||
"kind": kind,
|
||||
"reasons": list(reasons),
|
||||
"exact_next_action": exact_next_action or _NEXT_ACTIONS[kind],
|
||||
"detail": dict(detail or {}),
|
||||
}
|
||||
|
||||
|
||||
def _allowed_result(checks: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed": True,
|
||||
"block": False,
|
||||
"blockers": [],
|
||||
"reasons": [],
|
||||
"exact_next_action": "proceed",
|
||||
"blocker_kind": None,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _blocked_result(
|
||||
blockers: list[dict[str, Any]],
|
||||
checks: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
primary = blockers[0]
|
||||
all_reasons: list[str] = []
|
||||
for b in blockers:
|
||||
for r in b.get("reasons") or []:
|
||||
if r not in all_reasons:
|
||||
all_reasons.append(r)
|
||||
return {
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"blockers": blockers,
|
||||
"reasons": all_reasons,
|
||||
"exact_next_action": primary["exact_next_action"],
|
||||
"blocker_kind": primary["kind"],
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def assess_anti_stomp_preflight(
|
||||
*,
|
||||
task: str | None = None,
|
||||
# repo/org
|
||||
remote: str | None = None,
|
||||
resolved_org: str | None = None,
|
||||
resolved_repo: str | None = None,
|
||||
local_remote_url: str | None = None,
|
||||
org_explicit: bool = False,
|
||||
repo_explicit: bool = False,
|
||||
check_repo: bool = True,
|
||||
# profile/role + capability
|
||||
profile_name: str | None = None,
|
||||
profile_role: str | None = None,
|
||||
required_role: str | None = None,
|
||||
required_permission: str | None = None,
|
||||
allowed_operations: Any = None,
|
||||
check_role: bool = True,
|
||||
# root checkout + worktree
|
||||
workspace_path: str | None = None,
|
||||
project_root: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
root_head_sha: str | None = None,
|
||||
root_porcelain: str | None = None,
|
||||
remote_master_sha: str | None = None,
|
||||
check_root_checkout: bool = True,
|
||||
check_worktree: bool = True,
|
||||
# stale runtime (master parity)
|
||||
startup_head: str | None = None,
|
||||
current_code_head: str | None = None,
|
||||
check_stale_runtime: bool = True,
|
||||
# lease ownership
|
||||
lease_required: bool = False,
|
||||
foreign_lease: bool | None = None,
|
||||
lease_owner_session: str | None = None,
|
||||
active_session_id: str | None = None,
|
||||
lease_owner_identity: str | None = None,
|
||||
active_identity: str | None = None,
|
||||
lease_reasons: list[str] | None = None,
|
||||
# terminal lock
|
||||
terminal_lock_blocks: bool | None = None,
|
||||
terminal_lock_reasons: list[str] | None = None,
|
||||
# expected head SHA
|
||||
require_head_sha: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
live_head_sha: str | None = None,
|
||||
# workflow hash
|
||||
workflow_hash_valid: bool | None = None,
|
||||
workflow_hash_reasons: list[str] | None = None,
|
||||
# source contamination (stable-branch push / approval contamination)
|
||||
source_contaminated: bool | None = None,
|
||||
contamination_reasons: list[str] | None = None,
|
||||
# manual bypass
|
||||
manual_bypass_attempted: bool = False,
|
||||
manual_bypass_reasons: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail-closed common anti-stomp assessment (pure).
|
||||
|
||||
Only checks for which facts are supplied (or which are required by the
|
||||
mutation category) are evaluated. Unsupplied optional facts are recorded
|
||||
as ``skipped`` so callers can see coverage without inventing defaults.
|
||||
|
||||
Returns a structured dict with ``allowed``/``block``, typed ``blockers``,
|
||||
aggregated ``reasons``, ``exact_next_action``, and per-check ``checks``.
|
||||
"""
|
||||
task_name = (task or "").strip()
|
||||
role = (profile_role or "").strip().lower() or None
|
||||
req_role = (required_role or "").strip().lower() or None
|
||||
req_perm = (required_permission or "").strip() or None
|
||||
checks: dict[str, Any] = {
|
||||
"task": task_name or None,
|
||||
"profile_name": profile_name,
|
||||
"profile_role": role,
|
||||
"required_role": req_role,
|
||||
"required_permission": req_perm,
|
||||
}
|
||||
blockers: list[dict[str, Any]] = []
|
||||
|
||||
# ── manual bypass (always first; never skippable when attempted) ─────────
|
||||
if manual_bypass_attempted:
|
||||
reasons = list(manual_bypass_reasons or [
|
||||
"manual state/mtime/source-file bypass attempt detected (fail closed)"
|
||||
])
|
||||
blockers.append(_blocker(BLOCKER_MANUAL_BYPASS, reasons))
|
||||
checks["manual_bypass"] = {"block": True, "reasons": reasons}
|
||||
else:
|
||||
checks["manual_bypass"] = {"block": False, "skipped": False}
|
||||
|
||||
# ── repo/org ─────────────────────────────────────────────────────────────
|
||||
if check_repo and resolved_org is not None and resolved_repo is not None:
|
||||
repo_assessment = remote_repo_guard.assess_remote_repo_match(
|
||||
remote=remote or "",
|
||||
resolved_org=resolved_org,
|
||||
resolved_repo=resolved_repo,
|
||||
local_remote_url=local_remote_url,
|
||||
org_explicit=org_explicit,
|
||||
repo_explicit=repo_explicit,
|
||||
)
|
||||
checks["repo"] = {
|
||||
"block": bool(repo_assessment.get("block")),
|
||||
"reasons": list(repo_assessment.get("reasons") or []),
|
||||
"resolved_org": resolved_org,
|
||||
"resolved_repo": resolved_repo,
|
||||
}
|
||||
if repo_assessment.get("block"):
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_WRONG_REPO,
|
||||
list(repo_assessment.get("reasons") or ["repo/org mismatch"]),
|
||||
detail={
|
||||
"resolved_org": resolved_org,
|
||||
"resolved_repo": resolved_repo,
|
||||
"local_remote_url": local_remote_url,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["repo"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── profile/role + capability ────────────────────────────────────────────
|
||||
# Role match OR possession of the task's required permission (Blocker A).
|
||||
# Reviewer/merger may run issue-comment-class tasks when they hold
|
||||
# gitea.issue.comment; unauthorized escalation without the permission
|
||||
# still fails closed.
|
||||
if check_role and req_role and role:
|
||||
authorized = authorization_compatible(
|
||||
role,
|
||||
req_role,
|
||||
required_permission=req_perm,
|
||||
allowed_operations=allowed_operations,
|
||||
)
|
||||
if not authorized:
|
||||
perm_clause = (
|
||||
f", required_permission='{req_perm}'" if req_perm else ""
|
||||
)
|
||||
reasons = [
|
||||
f"active profile role '{role}' is not authorized for task "
|
||||
f"'{task_name or '(unknown)'}' (required_role='{req_role}'"
|
||||
f"{perm_clause}; profile={profile_name or '(unknown)'})"
|
||||
]
|
||||
checks["role"] = {
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"required_permission": req_perm,
|
||||
"capability_authorized": False,
|
||||
}
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_WRONG_ROLE,
|
||||
reasons,
|
||||
detail={
|
||||
"profile_name": profile_name,
|
||||
"profile_role": role,
|
||||
"required_role": req_role,
|
||||
"required_permission": req_perm,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["role"] = {
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"required_permission": req_perm,
|
||||
"capability_authorized": bool(
|
||||
req_perm
|
||||
and allowed_operations is not None
|
||||
and req_perm in {
|
||||
str(o).strip() for o in (allowed_operations or [])
|
||||
if o is not None
|
||||
}
|
||||
and not roles_compatible(role, req_role)
|
||||
),
|
||||
}
|
||||
else:
|
||||
checks["role"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── stale runtime (master parity) ────────────────────────────────────────
|
||||
if check_stale_runtime and (
|
||||
startup_head is not None or current_code_head is not None
|
||||
):
|
||||
parity = master_parity_gate.assess_master_parity(
|
||||
{"startup_head": startup_head},
|
||||
current_code_head,
|
||||
)
|
||||
stale_reasons = master_parity_gate.parity_block_reasons(parity)
|
||||
checks["stale_runtime"] = {
|
||||
"block": bool(stale_reasons),
|
||||
"reasons": list(stale_reasons),
|
||||
"startup_head": startup_head,
|
||||
"current_code_head": current_code_head,
|
||||
"stale": bool(parity.get("stale")),
|
||||
}
|
||||
if stale_reasons:
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_STALE_RUNTIME,
|
||||
list(stale_reasons),
|
||||
detail={
|
||||
"startup_head": startup_head,
|
||||
"current_code_head": current_code_head,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["stale_runtime"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── root checkout ────────────────────────────────────────────────────────
|
||||
if (
|
||||
check_root_checkout
|
||||
and workspace_path is not None
|
||||
and project_root is not None
|
||||
and root_porcelain is not None
|
||||
):
|
||||
root_assessment = root_checkout_guard.assess_root_checkout_guard(
|
||||
workspace_path=workspace_path,
|
||||
canonical_repo_root=project_root,
|
||||
current_branch=current_branch,
|
||||
head_sha=root_head_sha,
|
||||
porcelain_status=root_porcelain,
|
||||
remote_master_sha=remote_master_sha,
|
||||
resolved_role=req_role or role,
|
||||
actual_role=role,
|
||||
)
|
||||
checks["root_checkout"] = {
|
||||
"block": bool(root_assessment.get("block")),
|
||||
"reasons": list(root_assessment.get("reasons") or []),
|
||||
}
|
||||
if root_assessment.get("block"):
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_ROOT_CHECKOUT,
|
||||
list(root_assessment.get("reasons") or [
|
||||
"control checkout is not clean master"
|
||||
]),
|
||||
detail={
|
||||
"workspace_path": workspace_path,
|
||||
"project_root": project_root,
|
||||
"current_branch": current_branch,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["root_checkout"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── worktree under branches (author) ─────────────────────────────────────
|
||||
worktree_role = role or req_role
|
||||
if (
|
||||
check_worktree
|
||||
and workspace_path is not None
|
||||
and project_root is not None
|
||||
and worktree_role in _BRANCHES_REQUIRED_ROLES
|
||||
):
|
||||
wt = author_mutation_worktree.assess_author_mutation_worktree(
|
||||
workspace_path=workspace_path,
|
||||
project_root=project_root,
|
||||
current_branch=current_branch,
|
||||
)
|
||||
checks["worktree"] = {
|
||||
"block": bool(wt.get("block")),
|
||||
"reasons": list(wt.get("reasons") or []),
|
||||
"under_branches": wt.get("under_branches"),
|
||||
}
|
||||
if wt.get("block"):
|
||||
blockers.append(
|
||||
_blocker(
|
||||
BLOCKER_WRONG_WORKTREE,
|
||||
list(wt.get("reasons") or [
|
||||
"mutation worktree is not under branches/"
|
||||
]),
|
||||
detail={
|
||||
"workspace_path": workspace_path,
|
||||
"project_root": project_root,
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks["worktree"] = {
|
||||
"block": False,
|
||||
"skipped": worktree_role not in _BRANCHES_REQUIRED_ROLES
|
||||
or workspace_path is None,
|
||||
}
|
||||
|
||||
# ── foreign lease ────────────────────────────────────────────────────────
|
||||
lease_needed = lease_required or (
|
||||
task_name.removeprefix("gitea_") in {
|
||||
t.removeprefix("gitea_") for t in _LEASE_AWARE_TASKS
|
||||
}
|
||||
and foreign_lease is not None
|
||||
)
|
||||
if lease_needed or foreign_lease is True:
|
||||
is_foreign = bool(foreign_lease)
|
||||
if foreign_lease is None and lease_required:
|
||||
# Ownership must be proven when lease_required; missing ownership
|
||||
# facts fail closed.
|
||||
owner_ok = (
|
||||
(not lease_owner_session and not active_session_id)
|
||||
or (
|
||||
lease_owner_session
|
||||
and active_session_id
|
||||
and lease_owner_session == active_session_id
|
||||
)
|
||||
)
|
||||
identity_ok = (
|
||||
(not lease_owner_identity and not active_identity)
|
||||
or (
|
||||
lease_owner_identity
|
||||
and active_identity
|
||||
and lease_owner_identity == active_identity
|
||||
)
|
||||
)
|
||||
if not owner_ok or not identity_ok:
|
||||
is_foreign = True
|
||||
reasons = list(lease_reasons or [])
|
||||
if is_foreign and not reasons:
|
||||
reasons = [
|
||||
"active lease is owned by a foreign session or identity "
|
||||
"(anti-stomp fail closed)"
|
||||
]
|
||||
checks["lease"] = {
|
||||
"block": is_foreign,
|
||||
"reasons": reasons if is_foreign else [],
|
||||
"lease_owner_session": lease_owner_session,
|
||||
"active_session_id": active_session_id,
|
||||
}
|
||||
if is_foreign:
|
||||
blockers.append(
|
||||
_blocker(BLOCKER_FOREIGN_LEASE, reasons)
|
||||
)
|
||||
else:
|
||||
checks["lease"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── terminal lock ────────────────────────────────────────────────────────
|
||||
if terminal_lock_blocks is not None:
|
||||
reasons = list(terminal_lock_reasons or [])
|
||||
if terminal_lock_blocks and not reasons:
|
||||
reasons = [
|
||||
"terminal review-decision lock is active for this head "
|
||||
"(anti-stomp fail closed)"
|
||||
]
|
||||
checks["terminal_lock"] = {
|
||||
"block": bool(terminal_lock_blocks),
|
||||
"reasons": reasons if terminal_lock_blocks else [],
|
||||
}
|
||||
if terminal_lock_blocks:
|
||||
blockers.append(_blocker(BLOCKER_TERMINAL_LOCK, reasons))
|
||||
else:
|
||||
checks["terminal_lock"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── expected head SHA ────────────────────────────────────────────────────
|
||||
if require_head_sha or (
|
||||
expected_head_sha is not None and live_head_sha is not None
|
||||
):
|
||||
exp = (expected_head_sha or "").strip().lower()
|
||||
live = (live_head_sha or "").strip().lower()
|
||||
mismatch = False
|
||||
reasons: list[str] = []
|
||||
if require_head_sha and not exp:
|
||||
mismatch = True
|
||||
reasons.append(
|
||||
"expected_head_sha is required before this mutation "
|
||||
"(anti-stomp fail closed)"
|
||||
)
|
||||
elif exp and live and exp != live:
|
||||
mismatch = True
|
||||
reasons.append(
|
||||
f"expected_head_sha '{exp}' does not match live PR head "
|
||||
f"'{live}' (anti-stomp fail closed; stale prompt data)"
|
||||
)
|
||||
elif require_head_sha and exp and not live:
|
||||
mismatch = True
|
||||
reasons.append(
|
||||
"live PR head SHA could not be determined to corroborate "
|
||||
"expected_head_sha (anti-stomp fail closed)"
|
||||
)
|
||||
checks["head_sha"] = {
|
||||
"block": mismatch,
|
||||
"reasons": reasons,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
"live_head_sha": live_head_sha,
|
||||
}
|
||||
if mismatch:
|
||||
blockers.append(_blocker(BLOCKER_HEAD_SHA, reasons))
|
||||
else:
|
||||
checks["head_sha"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── workflow hash ────────────────────────────────────────────────────────
|
||||
if workflow_hash_valid is not None:
|
||||
reasons = list(workflow_hash_reasons or [])
|
||||
if not workflow_hash_valid and not reasons:
|
||||
reasons = [
|
||||
"workflow load proof missing or hash stale "
|
||||
"(anti-stomp fail closed)"
|
||||
]
|
||||
checks["workflow_hash"] = {
|
||||
"block": not bool(workflow_hash_valid),
|
||||
"reasons": reasons if not workflow_hash_valid else [],
|
||||
}
|
||||
if not workflow_hash_valid:
|
||||
blockers.append(_blocker(BLOCKER_WORKFLOW_HASH, reasons))
|
||||
else:
|
||||
checks["workflow_hash"] = {"block": False, "skipped": True}
|
||||
|
||||
# ── source contamination ─────────────────────────────────────────────────
|
||||
if source_contaminated is not None:
|
||||
reasons = list(contamination_reasons or [])
|
||||
if source_contaminated and not reasons:
|
||||
reasons = [
|
||||
"session is source-contaminated (stable-branch push or "
|
||||
"contaminated approval path); anti-stomp fail closed"
|
||||
]
|
||||
checks["source_contamination"] = {
|
||||
"block": bool(source_contaminated),
|
||||
"reasons": reasons if source_contaminated else [],
|
||||
}
|
||||
if source_contaminated:
|
||||
blockers.append(
|
||||
_blocker(BLOCKER_SOURCE_CONTAMINATION, reasons)
|
||||
)
|
||||
else:
|
||||
checks["source_contamination"] = {"block": False, "skipped": True}
|
||||
|
||||
if blockers:
|
||||
return _blocked_result(blockers, checks)
|
||||
return _allowed_result(checks)
|
||||
|
||||
|
||||
def format_anti_stomp_error(assessment: dict[str, Any]) -> str:
|
||||
"""Single RuntimeError message for MCP mutation gates."""
|
||||
kind = assessment.get("blocker_kind") or "unknown"
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons")
|
||||
or ["anti-stomp preflight blocked the mutation"]
|
||||
)
|
||||
next_action = assessment.get("exact_next_action") or "stop and diagnose"
|
||||
return (
|
||||
f"Anti-stomp preflight (#604) blocked mutation "
|
||||
f"[{kind}]: {reasons}. "
|
||||
f"exact_next_action: {next_action}"
|
||||
)
|
||||
|
||||
|
||||
def block_response(
|
||||
assessment: dict[str, Any],
|
||||
**extra_fields: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Structured tool-return payload for a blocked mutation."""
|
||||
payload = {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"blocked": True,
|
||||
"anti_stomp": True,
|
||||
"blocker_kind": assessment.get("blocker_kind"),
|
||||
"blockers": list(assessment.get("blockers") or []),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"exact_next_action": assessment.get("exact_next_action"),
|
||||
"checks": assessment.get("checks") or {},
|
||||
}
|
||||
payload.update(extra_fields)
|
||||
return payload
|
||||
|
||||
|
||||
def contamination_from_stable_marker(
|
||||
marker: dict | None,
|
||||
*,
|
||||
task: str | None,
|
||||
actual_role: str | None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Derive source-contamination facts from a #671 durable marker."""
|
||||
if not marker:
|
||||
return False, []
|
||||
gate = stable_branch_push_guard.assess_contamination_gate(
|
||||
marker,
|
||||
task=task,
|
||||
actual_role=actual_role,
|
||||
)
|
||||
if gate.get("block"):
|
||||
return True, list(gate.get("reasons") or [])
|
||||
return False, []
|
||||
+75
-686
File diff suppressed because it is too large
Load Diff
+13
-457
@@ -1,4 +1,4 @@
|
||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).
|
||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
|
||||
|
||||
#332 correctly hard-stops a reviewer session after a terminal live review
|
||||
mutation. After #559 those locks are durable on disk, so they can outlive the
|
||||
@@ -12,11 +12,6 @@ on head B of the **same open PR**. Same-head duplicates remain fail-closed.
|
||||
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
|
||||
(#594); new-head re-review is allowed without deleting the durable lock.
|
||||
|
||||
#693 scopes the terminal boundary by **PR number**: a terminal on open PR A
|
||||
must not block a fresh formal decision on open PR B on the same durable
|
||||
profile lock (cross-PR isolation). Correction authorization is scoped to the
|
||||
named prior review's PR/head and cannot unlock a different PR.
|
||||
|
||||
This module is pure policy (no Gitea I/O). The MCP tool
|
||||
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
||||
helpers, and only then clears durable state when cleanup is allowed.
|
||||
@@ -85,45 +80,17 @@ def last_terminal_mutation(lock: dict | None) -> dict | None:
|
||||
return terminals[-1] if terminals else None
|
||||
|
||||
|
||||
def correction_applies(
|
||||
lock: dict | None,
|
||||
*,
|
||||
pr_number: int | None = None,
|
||||
expected_head_sha: str | None = None,
|
||||
) -> bool:
|
||||
"""True when operator correction is active and scoped to the target (#693).
|
||||
|
||||
Global ``correction_authorized`` alone is not enough: correction must name
|
||||
the same PR (and head when recorded) as the decision being re-opened.
|
||||
Missing scope fields on legacy locks fail closed (do not apply).
|
||||
"""
|
||||
if not lock or not lock.get("correction_authorized"):
|
||||
return False
|
||||
corr_pr = lock.get("correction_pr_number")
|
||||
if corr_pr is None:
|
||||
# Legacy unscoped correction — refuse as generic unlock (#693).
|
||||
return False
|
||||
if pr_number is not None and int(corr_pr) != int(pr_number):
|
||||
return False
|
||||
corr_head = normalize_head_sha(lock.get("correction_head_sha"))
|
||||
target = normalize_head_sha(expected_head_sha)
|
||||
if corr_head and target and not heads_equal(corr_head, target):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def prior_live_mutations_block_boundary(
|
||||
lock: dict | None,
|
||||
*,
|
||||
pr_number: int,
|
||||
expected_head_sha: str | None,
|
||||
) -> bool:
|
||||
"""True when prior live mutations block a new decision for PR+head (#620/#693).
|
||||
"""True when prior live mutations block a new decision for PR+head (#620).
|
||||
|
||||
Historical terminals on a **different head of the same PR** do not block.
|
||||
Terminals on a **different PR** do not block (#693 cross-PR isolation).
|
||||
Same PR + same head (or same PR without a comparable head SHA) blocks
|
||||
unless a scoped correction applies.
|
||||
Any prior mutation on another PR, the same head, or without a comparable
|
||||
head SHA fails closed (blocks).
|
||||
|
||||
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
||||
that only stored ``ready_expected_head_sha`` still compare correctly
|
||||
@@ -131,9 +98,7 @@ def prior_live_mutations_block_boundary(
|
||||
"""
|
||||
if not lock:
|
||||
return False
|
||||
if correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
if lock.get("correction_authorized"):
|
||||
return False
|
||||
prior = list(lock.get("live_mutations") or [])
|
||||
if not prior:
|
||||
@@ -143,14 +108,10 @@ def prior_live_mutations_block_boundary(
|
||||
if not isinstance(m, dict):
|
||||
return True
|
||||
m_pr = m.get("pr_number")
|
||||
if m_pr is None:
|
||||
return True # fail closed — unscoped mutation
|
||||
if int(m_pr) != int(pr_number):
|
||||
# #693: foreign-PR history on the shared durable lock does not block.
|
||||
continue
|
||||
m_head = mutation_head_sha(m, lock)
|
||||
if (
|
||||
target
|
||||
m_pr == pr_number
|
||||
and target
|
||||
and m_head
|
||||
and not heads_equal(m_head, target)
|
||||
):
|
||||
@@ -167,34 +128,23 @@ def terminal_boundary_allows_fresh_decision(
|
||||
expected_head_sha: str | None,
|
||||
operation: str,
|
||||
) -> bool:
|
||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).
|
||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
|
||||
|
||||
For ``mark_ready`` / ``review`` / ``resume``:
|
||||
|
||||
* **same PR, different head** — allowed (#620)
|
||||
* **different PR than last terminal** — allowed (#693 cross-PR isolation)
|
||||
* **same PR, same head** — not allowed (unless scoped correction)
|
||||
|
||||
Merge is never reopened by head change or cross-PR isolation (approved
|
||||
head merge path is separate).
|
||||
Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when
|
||||
the requested head differs from the last terminal's head. Merge is never
|
||||
reopened by head change (approved head merge path is separate).
|
||||
"""
|
||||
if operation not in ("mark_ready", "review", "resume"):
|
||||
return False
|
||||
if not lock:
|
||||
return False
|
||||
if correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
if lock.get("correction_authorized"):
|
||||
return True
|
||||
last = last_terminal_mutation(lock)
|
||||
if last is None:
|
||||
return True
|
||||
last_pr = last.get("pr_number")
|
||||
if last_pr is None:
|
||||
if last.get("pr_number") != pr_number:
|
||||
return False
|
||||
if int(last_pr) != int(pr_number):
|
||||
# #693: last terminal belongs to another PR — do not hard-stop this PR.
|
||||
return True
|
||||
locked_head = mutation_head_sha(last, lock)
|
||||
target = normalize_head_sha(expected_head_sha)
|
||||
if not locked_head or not target:
|
||||
@@ -490,400 +440,6 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --- #693 classification / recovery / correction audit -----------------------
|
||||
|
||||
# Deterministic classification labels for diagnostics and recovery routing.
|
||||
CLASS_NO_LOCK = "no_lock"
|
||||
CLASS_READY_FOR_TARGET = "ready_for_target"
|
||||
CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head"
|
||||
CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head"
|
||||
CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head"
|
||||
CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal"
|
||||
CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed"
|
||||
CLASS_CORRECTION_ACTIVE = "correction_active"
|
||||
CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch"
|
||||
CLASS_AMBIGUOUS = "ambiguous"
|
||||
|
||||
|
||||
def classify_review_decision_lock(
|
||||
lock: dict | None,
|
||||
*,
|
||||
target_pr_number: int | None = None,
|
||||
target_head_sha: str | None = None,
|
||||
pr_live: dict | None = None,
|
||||
pr_lookup_error: str | None = None,
|
||||
active_profile_identity: str | None = None,
|
||||
session_blockers: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Deterministic review-decision-lock classification (#693).
|
||||
|
||||
Returns classification, exact next_action, owner/evidence fields, and
|
||||
whether mark_final / cleanup / correction are appropriate.
|
||||
"""
|
||||
summary = lock_summary(lock)
|
||||
target_head = normalize_head_sha(target_head_sha)
|
||||
result: dict[str, Any] = {
|
||||
"classification": CLASS_NO_LOCK,
|
||||
"has_lock": lock is not None,
|
||||
"lock_summary": summary,
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": target_head,
|
||||
"last_terminal_pr": None,
|
||||
"last_terminal_action": None,
|
||||
"locked_head_sha": None,
|
||||
"owner_profile_identity": (summary or {}).get("profile_identity"),
|
||||
"session_profile": (summary or {}).get("session_profile"),
|
||||
"updated_at": (summary or {}).get("updated_at"),
|
||||
"correction_authorized": bool((lock or {}).get("correction_authorized")),
|
||||
"correction_pr_number": (lock or {}).get("correction_pr_number"),
|
||||
"correction_head_sha": normalize_head_sha(
|
||||
(lock or {}).get("correction_head_sha")
|
||||
),
|
||||
"mark_final_allowed": True,
|
||||
"cleanup_allowed": False,
|
||||
"correction_eligible": False,
|
||||
"next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final",
|
||||
"reasons": [],
|
||||
"evidence": {},
|
||||
}
|
||||
|
||||
if lock is None:
|
||||
result["reasons"].append("no review decision lock present")
|
||||
return result
|
||||
|
||||
session_blockers = list(session_blockers or [])
|
||||
if session_blockers:
|
||||
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
"reconnect matching prgs-reviewer session or wait for lock TTL; "
|
||||
"do not use gitea_authorize_review_correction as unlock; "
|
||||
"do not delete session-state files"
|
||||
)
|
||||
result["reasons"].extend(session_blockers)
|
||||
return result
|
||||
|
||||
stored = (
|
||||
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
||||
.strip()
|
||||
)
|
||||
active = (active_profile_identity or "").strip()
|
||||
if active and stored and active != stored:
|
||||
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
f"switch to profile '{stored}' or wait for moot cleanup; "
|
||||
"do not use correction authorization as a generic unlock"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"lock profile '{stored}' does not match active '{active}'"
|
||||
)
|
||||
return result
|
||||
|
||||
last = last_terminal_mutation(lock)
|
||||
if last is None:
|
||||
ready_pr = lock.get("ready_pr_number")
|
||||
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
||||
if (
|
||||
target_pr_number is not None
|
||||
and ready_pr == target_pr_number
|
||||
and ready_head
|
||||
and target_head
|
||||
and heads_equal(ready_head, target_head)
|
||||
and lock.get("final_review_decision_ready")
|
||||
):
|
||||
result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD
|
||||
result["mark_final_allowed"] = True # idempotent re-mark
|
||||
result["next_action"] = (
|
||||
"gitea_submit_pr_review with final_review_decision_ready=true "
|
||||
"for the ready PR/head"
|
||||
)
|
||||
result["reasons"].append(
|
||||
"final decision already marked ready for this PR/head (idempotent)"
|
||||
)
|
||||
return result
|
||||
result["classification"] = CLASS_READY_FOR_TARGET
|
||||
result["next_action"] = "gitea_mark_final_review_decision then submit"
|
||||
result["reasons"].append("no terminal live mutations; mark_final allowed")
|
||||
return result
|
||||
|
||||
last_pr = last.get("pr_number")
|
||||
last_action = last.get("action")
|
||||
locked_head = mutation_head_sha(last, lock)
|
||||
result["last_terminal_pr"] = last_pr
|
||||
result["last_terminal_action"] = last_action
|
||||
result["locked_head_sha"] = locked_head
|
||||
result["evidence"] = {
|
||||
"last_review_id": last.get("review_id"),
|
||||
"live_mutations_count": len(lock.get("live_mutations") or []),
|
||||
}
|
||||
|
||||
if correction_applies(
|
||||
lock, pr_number=target_pr_number, expected_head_sha=target_head
|
||||
):
|
||||
result["classification"] = CLASS_CORRECTION_ACTIVE
|
||||
result["mark_final_allowed"] = True
|
||||
result["next_action"] = (
|
||||
"gitea_mark_final_review_decision for the correction-scoped PR/head, "
|
||||
"then submit once"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"scoped correction active for PR #{lock.get('correction_pr_number')}"
|
||||
)
|
||||
return result
|
||||
|
||||
# Live state of last terminal PR when provided.
|
||||
live = None
|
||||
if pr_lookup_error:
|
||||
result["classification"] = CLASS_AMBIGUOUS
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = (
|
||||
"retry diagnosis after live PR state is available; "
|
||||
"fail closed under ambiguous evidence"
|
||||
)
|
||||
result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}")
|
||||
return result
|
||||
if pr_live is not None:
|
||||
live = classify_pr_live_state(pr_live)
|
||||
result["evidence"]["last_terminal_pr_state"] = live.get("pr_state")
|
||||
result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged")
|
||||
if live.get("pr_merged_or_closed"):
|
||||
result["classification"] = CLASS_MOOT_MERGED_CLOSED
|
||||
result["cleanup_allowed"] = True
|
||||
result["mark_final_allowed"] = target_pr_number is not None and (
|
||||
int(last_pr) != int(target_pr_number)
|
||||
if last_pr is not None and target_pr_number is not None
|
||||
else True
|
||||
)
|
||||
result["next_action"] = (
|
||||
"gitea_cleanup_stale_review_decision_lock(apply=true, "
|
||||
f"expected_terminal_pr={last_pr}) then mark_final on the target PR"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed"
|
||||
)
|
||||
return result
|
||||
|
||||
if target_pr_number is None:
|
||||
result["classification"] = CLASS_AMBIGUOUS
|
||||
result["mark_final_allowed"] = False
|
||||
result["next_action"] = "re-run diagnosis with target_pr_number"
|
||||
result["reasons"].append("target_pr_number required for open-PR classification")
|
||||
return result
|
||||
|
||||
if last_pr is not None and int(last_pr) != int(target_pr_number):
|
||||
# Cross-PR isolation: foreign terminal is history, not a block.
|
||||
result["classification"] = CLASS_FOREIGN_PR_TERMINAL
|
||||
result["mark_final_allowed"] = True
|
||||
result["correction_eligible"] = False # must not use correction to "unlock"
|
||||
result["next_action"] = (
|
||||
f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) "
|
||||
f"— foreign terminal on PR #{last_pr} does not block (#693); "
|
||||
"do not call gitea_authorize_review_correction for cross-PR unlock"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; "
|
||||
f"target PR #{target_pr_number} is isolated (#693)"
|
||||
)
|
||||
return result
|
||||
|
||||
# Same PR as target.
|
||||
if (
|
||||
locked_head
|
||||
and target_head
|
||||
and not heads_equal(locked_head, target_head)
|
||||
):
|
||||
result["classification"] = CLASS_STALE_SUPERSEDED_HEAD
|
||||
result["mark_final_allowed"] = True
|
||||
result["next_action"] = (
|
||||
f"gitea_mark_final_review_decision with expected_head_sha="
|
||||
f"{target_head} (#620 same-PR new head)"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal {last_action} on head {locked_head[:12]}…; target head "
|
||||
f"{target_head[:12]}… — fresh formal review allowed without cleanup"
|
||||
)
|
||||
return result
|
||||
|
||||
# Same PR + same head (or missing head comparison) — active terminal.
|
||||
result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD
|
||||
result["mark_final_allowed"] = False
|
||||
result["correction_eligible"] = True
|
||||
result["next_action"] = (
|
||||
"if the prior verdict was mistaken: gitea_authorize_review_correction "
|
||||
f"with prior_review_id={last.get('review_id')}, "
|
||||
f"prior_review_state={last_action}, target_pr_number={target_pr_number}, "
|
||||
"operator_authorized=true, then re-mark once; "
|
||||
"if the prior verdict is correct: stop and hand off (do not duplicate)"
|
||||
)
|
||||
result["reasons"].append(
|
||||
f"terminal {last_action} already recorded for PR #{target_pr_number} "
|
||||
f"at this head; same-head duplicate blocked (#332/#620)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_precise_mark_final_failure(
|
||||
classification: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""One precise recovery instruction + durable issue handoff payload (#693 AC4/8)."""
|
||||
cls = classification.get("classification")
|
||||
next_action = classification.get("next_action") or (
|
||||
"stop and create a durable Gitea issue with lock evidence"
|
||||
)
|
||||
reasons = list(classification.get("reasons") or [])
|
||||
reasons.append(f"exact_next_action: {next_action}")
|
||||
handoff = {
|
||||
"required": cls
|
||||
in (
|
||||
CLASS_AMBIGUOUS,
|
||||
CLASS_SESSION_OR_PROFILE_MISMATCH,
|
||||
CLASS_TERMINAL_ACTIVE_SAME_HEAD,
|
||||
)
|
||||
and not classification.get("mark_final_allowed"),
|
||||
"title_hint": (
|
||||
"Review-decision-lock recovery failed during formal review"
|
||||
),
|
||||
"classification": cls,
|
||||
"exact_next_action": next_action,
|
||||
"owner_profile_identity": classification.get("owner_profile_identity"),
|
||||
"last_terminal_pr": classification.get("last_terminal_pr"),
|
||||
"last_terminal_action": classification.get("last_terminal_action"),
|
||||
"locked_head_sha": classification.get("locked_head_sha"),
|
||||
"target_pr_number": classification.get("target_pr_number"),
|
||||
"target_head_sha": classification.get("target_head_sha"),
|
||||
"evidence": classification.get("evidence") or {},
|
||||
"instruction": (
|
||||
"If recovery cannot complete with the exact_next_action above, "
|
||||
"create or update a durable Gitea issue with this payload and stop; "
|
||||
"do not delete session-state files; do not misuse correction as unlock."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"reasons": reasons,
|
||||
"classification": cls,
|
||||
"exact_next_action": next_action,
|
||||
"durable_issue_handoff": handoff,
|
||||
}
|
||||
|
||||
|
||||
def build_correction_audit_record(
|
||||
*,
|
||||
prior_review_id: int | None,
|
||||
prior_review_state: str | None,
|
||||
target_pr_number: int | None,
|
||||
target_head_sha: str | None,
|
||||
reason: str | None,
|
||||
actor_username: str | None,
|
||||
profile_name: str | None,
|
||||
authorized: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Structured durable audit for review-correction authorization (#693)."""
|
||||
return {
|
||||
"event": "review_correction_authorization",
|
||||
"issue_ref": "#693",
|
||||
"authorized": bool(authorized),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"actor_username": actor_username,
|
||||
"profile_name": profile_name,
|
||||
"prior_review_id": prior_review_id,
|
||||
"prior_review_state": prior_review_state,
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": normalize_head_sha(target_head_sha),
|
||||
"reason": reason,
|
||||
"scope": "same_pr_head_only",
|
||||
}
|
||||
|
||||
|
||||
def format_correction_audit_comment(audit: dict[str, Any]) -> str:
|
||||
"""Markdown body for a thread-visible correction authorization audit."""
|
||||
status = "AUTHORIZED" if audit.get("authorized") else "DENIED"
|
||||
lines = [
|
||||
"## Review correction authorization audit (#693)",
|
||||
"",
|
||||
f"Status: **{status}**",
|
||||
"",
|
||||
f"- actor: `{audit.get('actor_username')}`",
|
||||
f"- profile: `{audit.get('profile_name')}`",
|
||||
f"- timestamp: `{audit.get('timestamp')}`",
|
||||
f"- prior_review_id: `{audit.get('prior_review_id')}`",
|
||||
f"- prior_review_state: `{audit.get('prior_review_state')}`",
|
||||
f"- target_pr: `#{audit.get('target_pr_number')}`",
|
||||
f"- target_head_sha: `{audit.get('target_head_sha')}`",
|
||||
f"- reason: {audit.get('reason')}",
|
||||
f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)",
|
||||
"",
|
||||
"This is **not** a generic decision-lock unlock. Cross-PR recovery "
|
||||
"uses isolation (#693) or moot cleanup (#594), never correction.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_open_pr_decision_lock_recovery(
|
||||
lock: dict | None,
|
||||
*,
|
||||
target_pr_number: int,
|
||||
target_head_sha: str | None,
|
||||
last_terminal_pr_live: dict | None = None,
|
||||
pr_lookup_error: str | None = None,
|
||||
active_profile_identity: str | None = None,
|
||||
session_blockers: list[str] | None = None,
|
||||
controller_recovery_authorized: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess guarded recovery for decision locks affecting an open target PR (#693).
|
||||
|
||||
Recovery here means *workflow recovery routing*, not necessarily lock wipe.
|
||||
Foreign-PR terminals are recoverable by isolation (mark_final allowed).
|
||||
Moot terminals use #594 cleanup. Same-head active terminals refuse wipe.
|
||||
"""
|
||||
classification = classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=target_pr_number,
|
||||
target_head_sha=target_head_sha,
|
||||
pr_live=last_terminal_pr_live,
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=active_profile_identity,
|
||||
session_blockers=session_blockers,
|
||||
)
|
||||
cls = classification["classification"]
|
||||
recovery_allowed = False
|
||||
recovery_mode = None
|
||||
if cls == CLASS_FOREIGN_PR_TERMINAL:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "cross_pr_isolation"
|
||||
elif cls == CLASS_STALE_SUPERSEDED_HEAD:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "same_pr_new_head"
|
||||
elif cls == CLASS_MOOT_MERGED_CLOSED:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "moot_cleanup"
|
||||
elif cls == CLASS_READY_FOR_TARGET:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "none_required"
|
||||
elif cls == CLASS_CORRECTION_ACTIVE:
|
||||
recovery_allowed = True
|
||||
recovery_mode = "scoped_correction"
|
||||
elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD:
|
||||
recovery_allowed = False
|
||||
recovery_mode = "correction_only_if_mistake"
|
||||
else:
|
||||
recovery_allowed = False
|
||||
recovery_mode = "fail_closed"
|
||||
|
||||
if recovery_mode == "moot_cleanup" and not controller_recovery_authorized:
|
||||
# Cleanup still needs apply + capability; assess reports path.
|
||||
pass
|
||||
|
||||
return {
|
||||
**classification,
|
||||
"recovery_allowed": recovery_allowed,
|
||||
"recovery_mode": recovery_mode,
|
||||
"controller_recovery_authorized": bool(controller_recovery_authorized),
|
||||
"manual_file_delete_forbidden": True,
|
||||
"correction_as_generic_unlock_forbidden": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #709 cross-profile cleanup, overwrite protection, recovery provenance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -60,15 +60,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.close",
|
||||
"role": "author",
|
||||
},
|
||||
# Non-closing PR metadata edits (title/body/base). Closing uses close_pr.
|
||||
"edit_pr": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
},
|
||||
"gitea_edit_pr": {
|
||||
"permission": "gitea.pr.create",
|
||||
"role": "author",
|
||||
},
|
||||
"address_pr_change_requests": {
|
||||
"permission": "gitea.branch.push",
|
||||
"role": "author",
|
||||
@@ -77,22 +68,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"submit_pr_review": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"merge_pr": {
|
||||
"permission": "gitea.pr.merge",
|
||||
"role": "merger",
|
||||
},
|
||||
"acquire_reviewer_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_acquire_reviewer_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #695 AC8: controller quarantine of contaminated formal reviews.
|
||||
# Apply path posts an append-only forensic audit comment (pr.comment).
|
||||
"quarantine_contaminated_review": {
|
||||
@@ -148,23 +127,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #693: read-only classification of durable decision locks (incl. open PRs).
|
||||
"diagnose_review_decision_lock": {
|
||||
"permission": "gitea.read",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_diagnose_review_decision_lock": {
|
||||
"permission": "gitea.read",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"authorize_review_correction": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
"gitea_authorize_review_correction": {
|
||||
"permission": "gitea.pr.review",
|
||||
"role": "reviewer",
|
||||
},
|
||||
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
|
||||
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
||||
"issue_irrecoverable_provenance_authorization": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,6 @@ 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",
|
||||
@@ -44,11 +40,9 @@ 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": mutations,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": corr_pr,
|
||||
"correction_head_sha": None,
|
||||
"correction_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -180,13 +174,12 @@ class TestHardStopHeadScope(unittest.TestCase):
|
||||
self.assertTrue(reasons)
|
||||
self.assertIn("#332", reasons[0])
|
||||
|
||||
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."""
|
||||
def test_rc_head_a_blocks_other_pr(self):
|
||||
_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.assertEqual(reasons, [])
|
||||
self.assertTrue(reasons)
|
||||
|
||||
def test_legacy_619_ledger_allows_new_head(self):
|
||||
"""PR #619-style durable lock without mutation head_sha."""
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""#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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,427 +0,0 @@
|
||||
"""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()
|
||||
@@ -2567,18 +2567,15 @@ 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)
|
||||
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,
|
||||
)
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
)
|
||||
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
|
||||
@@ -2592,7 +2589,6 @@ 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"]))
|
||||
@@ -2603,22 +2599,10 @@ 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 {}
|
||||
@@ -2631,7 +2615,6 @@ 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,7 +2672,6 @@ 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__":
|
||||
|
||||
+2
-101
@@ -68,6 +68,8 @@ 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"},
|
||||
@@ -283,104 +285,3 @@ 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 [])))
|
||||
|
||||
|
||||
@@ -188,32 +188,20 @@ class TestActiveHardStopPreserved(unittest.TestCase):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
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_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_request_changes_still_blocks_same_pr(self):
|
||||
_seed([RC_OPEN]) # request_changes on PR #701
|
||||
def test_request_changes_still_blocks_everything(self):
|
||||
_seed([RC_OPEN])
|
||||
for op in ("merge", "mark_ready", "review"):
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(701, op),
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, op),
|
||||
msg=op,
|
||||
)
|
||||
# Foreign PR review path is open (#693).
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332).
|
||||
|
||||
Extends the single-terminal-decision machinery (#211): after a live
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
import os
|
||||
import unittest
|
||||
@@ -15,10 +15,7 @@ import mcp_server
|
||||
HEAD_SHA = "a" * 40
|
||||
|
||||
|
||||
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")
|
||||
def _lock(mutations=None, correction=False):
|
||||
return {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
@@ -32,11 +29,9 @@ def _lock(mutations=None, correction=False, correction_pr=None):
|
||||
"ready_remote": None,
|
||||
"ready_org": None,
|
||||
"ready_repo": None,
|
||||
"live_mutations": mutations,
|
||||
"live_mutations": list(mutations or []),
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": correction_pr if correction else None,
|
||||
"correction_head_sha": None,
|
||||
"correction_reason": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -69,35 +64,25 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||
|
||||
def test_request_changes_blocks_same_pr_allows_other_pr_review(self):
|
||||
def test_request_changes_blocks_everything(self):
|
||||
_seed([RC_A])
|
||||
# Same PR: still hard-stopped for mark_ready/review/merge.
|
||||
for op in ("merge", "mark_ready", "review"):
|
||||
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"))
|
||||
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])
|
||||
|
||||
def test_approve_allows_same_pr_merge_and_other_pr_review(self):
|
||||
def test_approve_allows_same_pr_merge_only(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"))
|
||||
# #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"), [])
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
|
||||
|
||||
def test_correction_reopens_review_path_only(self):
|
||||
_seed([RC_A], correction=True)
|
||||
@@ -105,11 +90,9 @@ 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"), [])
|
||||
# scoped correction never opens a cross-PR merge
|
||||
# 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):
|
||||
@@ -143,29 +126,14 @@ class TestMarkFinalHardStopWiring(unittest.TestCase):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_mark_ready_same_pr_blocked_after_terminal_mutation(self):
|
||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
||||
_seed([RC_A])
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=5, action="approve", remote="prgs",
|
||||
expected_head_sha=HEAD_SHA)
|
||||
pr_number=6, action="approve", remote="prgs")
|
||||
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