Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0801e2455b | ||
|
|
ec2433f13b | ||
|
|
899ef8ec7f | ||
|
|
58d6b5844f | ||
|
|
c1c61e9b15 | ||
|
|
fca3296883 | ||
|
|
d7b0b0e772 | ||
|
|
83bc7246ff | ||
|
|
29c2cf2ef6 | ||
|
|
4a160e0e0c | ||
|
|
14d885b2d2 | ||
|
|
a3dbf668fd | ||
|
|
fb9191e559 | ||
|
|
07083eae09 | ||
|
|
d867acd9db | ||
|
|
a19af4de22 | ||
|
|
751403d12b | ||
|
|
3e78db5d28 | ||
|
|
e33d8874a5 | ||
|
|
8814c04e3a | ||
|
|
7c77df0c52 | ||
|
|
c54f41c38d | ||
|
|
f34ec86b90 | ||
|
|
7cb65028fd | ||
|
|
4f894009f6 | ||
|
|
acf2eaec01 | ||
|
|
e8ca5202a0 | ||
|
|
99640f90d0 | ||
|
|
1421fa8568 | ||
|
|
9e144db75c | ||
|
|
c780ded653 | ||
|
|
dc899d23c8 | ||
|
|
943d40270e | ||
|
|
d936da5c87 | ||
|
|
29ffe1407f | ||
|
|
632c568865 | ||
|
|
c2fc2683b9 | ||
|
|
889931d553 | ||
|
|
1844e29880 | ||
|
|
3fd02a3c63 | ||
|
|
ea8e186549 | ||
|
|
23e366d9d6 |
@@ -0,0 +1,807 @@
|
|||||||
|
"""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, []
|
||||||
@@ -45,6 +45,7 @@ authenticated capability set. Each profile defines the following fields:
|
|||||||
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
| `authenticated_username` | string | The Gitea login this profile authenticates as (verified at runtime via `gitea_whoami`, not trusted from config). |
|
||||||
| `allowed_operations` | list | Operation categories this profile may perform. |
|
| `allowed_operations` | list | Operation categories this profile may perform. |
|
||||||
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
| `forbidden_operations` | list | Operation categories this profile must never perform. |
|
||||||
|
| `allowed_repositories` | list | Optional. Canonical `owner/repository` slugs this profile may bind to. An authorization boundary, not the binding itself — see [Repository scope](#repository-scope-714). |
|
||||||
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
| `token_source_name` | string | The *name* of the secret source (e.g. env var name or secret key). **Never the token value.** |
|
||||||
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
| `audit_label` | string | Short label attached to audit records for actions by this profile. |
|
||||||
| `can_approve_prs` | bool | May submit an approving PR review. |
|
| `can_approve_prs` | bool | May submit an approving PR review. |
|
||||||
@@ -57,6 +58,47 @@ authenticated capability set. Each profile defines the following fields:
|
|||||||
name), never the token itself. Token values are never part of a profile object,
|
name), never the token itself. Token values are never part of a profile object,
|
||||||
never logged, never returned by a tool, and never committed.
|
never logged, never returned by a tool, and never committed.
|
||||||
|
|
||||||
|
## Repository scope (#714)
|
||||||
|
|
||||||
|
`allowed_repositories` declares the canonical `owner/repository` slugs a profile
|
||||||
|
may operate on:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"prgs-author": {
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is an **authorization boundary, not the session binding**. The binding is
|
||||||
|
derived and enforced like this:
|
||||||
|
|
||||||
|
1. The session repository is derived from the **verified, workspace-aligned git
|
||||||
|
remote** — never from a caller-supplied `org`/`repo` argument, and never from
|
||||||
|
the `REMOTES` table (whose entries are default *targets*: `prgs` defaults to
|
||||||
|
`Timesheet`, which is not this project).
|
||||||
|
2. That workspace-derived slug is validated against `allowed_repositories`.
|
||||||
|
3. The session binds immutably to that one canonical `owner/repository`. The
|
||||||
|
organization is derived from the slug; there is no independent,
|
||||||
|
caller-controlled organization value.
|
||||||
|
4. If a profile authorizes several repositories, the verified workspace still
|
||||||
|
selects exactly one. A session never binds to the whole list and never
|
||||||
|
switches between entries.
|
||||||
|
|
||||||
|
Fail-closed rules:
|
||||||
|
|
||||||
|
- Activation is rejected when the workspace repository is absent from the
|
||||||
|
allowlist.
|
||||||
|
- A mutation is rejected when no verified workspace repository can be
|
||||||
|
established.
|
||||||
|
- A tool-level `org`/`repo` override that disagrees with the binding is rejected
|
||||||
|
before the mutation runs.
|
||||||
|
- A mutation request can never establish, complete, or replace the binding.
|
||||||
|
|
||||||
|
The field is **config-only**: no environment variable can widen or forge it. It
|
||||||
|
is optional — a profile that omits it keeps the previous behaviour, so existing
|
||||||
|
static-profile namespaces stay functional until an operator provisions the
|
||||||
|
scope. Provisioning it is what activates enforcement for that profile.
|
||||||
|
|
||||||
## Example profiles
|
## Example profiles
|
||||||
|
|
||||||
The following are the reference profiles. Booleans express intended capability
|
The following are the reference profiles. Booleans express intended capability
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"execution_profile": "example-author",
|
"execution_profile": "example-author",
|
||||||
"audit_label": "example-author",
|
"audit_label": "example-author",
|
||||||
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment", "issue.comment"],
|
||||||
"forbidden_operations": ["approve", "request_changes", "merge"]
|
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||||
},
|
},
|
||||||
|
|||||||
+47
-3
@@ -182,11 +182,51 @@ def get_auth_header(host):
|
|||||||
|
|
||||||
def resolve_remote(args):
|
def resolve_remote(args):
|
||||||
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
"""Given parsed argparse args with --remote/--host/--org/--repo,
|
||||||
return (host, org, repo) with overrides applied."""
|
return (host, org, repo) with overrides applied.
|
||||||
|
|
||||||
|
#714 / #530: when the caller omits org and/or repo, prefer the
|
||||||
|
workspace-aligned git remote over REMOTES defaults (e.g. bare
|
||||||
|
``--remote prgs`` must not force Timesheet when the checkout is
|
||||||
|
Gitea-Tools). Explicit --org/--repo always win.
|
||||||
|
"""
|
||||||
profile = REMOTES[args.remote]
|
profile = REMOTES[args.remote]
|
||||||
host = args.host or profile["host"]
|
host = args.host or profile["host"]
|
||||||
org = args.org or profile["org"]
|
org_explicit = getattr(args, "org", None) is not None
|
||||||
repo = args.repo or profile["repo"]
|
repo_explicit = getattr(args, "repo", None) is not None
|
||||||
|
org = args.org if org_explicit else profile["org"]
|
||||||
|
repo = args.repo if repo_explicit else profile["repo"]
|
||||||
|
if not org_explicit or not repo_explicit:
|
||||||
|
try:
|
||||||
|
import remote_repo_guard
|
||||||
|
import subprocess
|
||||||
|
# Prefer the named remote URL when present; fall back to origin.
|
||||||
|
url = None
|
||||||
|
for remote_name in (args.remote, "origin"):
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "remote", "get-url", remote_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0 and (proc.stdout or "").strip():
|
||||||
|
url = proc.stdout.strip()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Allow tests to inject a deterministic remote URL without Git.
|
||||||
|
env_url = os.environ.get("GITEA_TEST_WORKSPACE_REMOTE_URL")
|
||||||
|
if env_url:
|
||||||
|
url = env_url
|
||||||
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(url)
|
||||||
|
if parsed:
|
||||||
|
if not org_explicit:
|
||||||
|
org = parsed[0]
|
||||||
|
if not repo_explicit:
|
||||||
|
repo = parsed[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return host, org, repo
|
return host, org, repo
|
||||||
|
|
||||||
|
|
||||||
@@ -758,6 +798,10 @@ def get_profile():
|
|||||||
"profile_name": name,
|
"profile_name": name,
|
||||||
"allowed_operations": ops,
|
"allowed_operations": ops,
|
||||||
"forbidden_operations": forbidden,
|
"forbidden_operations": forbidden,
|
||||||
|
# #714 repository authorization boundary. Config-only on purpose: an
|
||||||
|
# environment variable must never widen or forge the set of
|
||||||
|
# repositories a session may bind to.
|
||||||
|
"allowed_repositories": _json_list("allowed_repositories"),
|
||||||
"audit_label": audit_label,
|
"audit_label": audit_label,
|
||||||
"token_source_name": token_source,
|
"token_source_name": token_source,
|
||||||
"auth_source_type": auth_type,
|
"auth_source_type": auth_type,
|
||||||
|
|||||||
@@ -475,6 +475,33 @@ def _require_enabled(kind, name, obj):
|
|||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_SCOPE_RE = re.compile(r"^[^/\s]+/[^/\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_allowed_repositories(name, raw):
|
||||||
|
"""Validate the optional per-profile repository authorization scope (#714).
|
||||||
|
|
||||||
|
``allowed_repositories`` is an authorization boundary of canonical
|
||||||
|
``owner/repository`` slugs. It is not the session binding: the verified
|
||||||
|
workspace repository selects exactly one entry at bind time. Absent means
|
||||||
|
"no repository scope configured" and is allowed, so existing profiles keep
|
||||||
|
working until an operator provisions the field.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' allowed_repositories must be a list of "
|
||||||
|
"'owner/repository' strings"
|
||||||
|
)
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, str) or not _REPO_SCOPE_RE.match(entry.strip()):
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' allowed_repositories entry {entry!r} is not "
|
||||||
|
"a canonical 'owner/repository' slug"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reject_inline_secrets(kind, name, obj):
|
def _reject_inline_secrets(kind, name, obj):
|
||||||
for key in _INLINE_SECRET_KEYS:
|
for key in _INLINE_SECRET_KEYS:
|
||||||
if key in obj:
|
if key in obj:
|
||||||
@@ -549,6 +576,7 @@ def _load_v2_contexts(data, path):
|
|||||||
forbidden = raw.get("forbidden_operations") or []
|
forbidden = raw.get("forbidden_operations") or []
|
||||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||||
|
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||||
|
|||||||
+2051
-258
File diff suppressed because it is too large
Load Diff
+90
-7
@@ -36,6 +36,15 @@ KIND_REVIEW_DRAFT = "review_draft"
|
|||||||
# other session proofs; a contaminated session fails closed on gated mutations
|
# other session proofs; a contaminated session fails closed on gated mutations
|
||||||
# until a reconciler audits and clears it.
|
# until a reconciler audits and clears it.
|
||||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||||
|
# Durable shadow of the in-memory reviewer session lease (#702). Written on
|
||||||
|
# every sanctioned record/heartbeat and removed on sanctioned clear, so a
|
||||||
|
# daemon that dies without teardown leaves provable orphan evidence (owner
|
||||||
|
# pid + session id) for the guarded lease-cleanup path. Never a substitute
|
||||||
|
# for the in-session lease: mutation gates ignore it.
|
||||||
|
KIND_REVIEWER_SESSION_LEASE = "reviewer_session_lease"
|
||||||
|
# Durable audit record written whenever the daemon's sanctioned stale-binding
|
||||||
|
# recovery clears an inherited GITEA_ACTIVE_WORKTREE binding (#702).
|
||||||
|
KIND_STALE_BINDING_RECOVERY = "stale_binding_recovery"
|
||||||
# #709: archive prior terminal decision ledgers instead of silent overwrite.
|
# #709: archive prior terminal decision ledgers instead of silent overwrite.
|
||||||
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
||||||
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
||||||
@@ -59,6 +68,9 @@ RECOVERY_CRITICAL_KINDS = frozenset(
|
|||||||
KIND_POST_MERGE_DECISION_RECOVERY,
|
KIND_POST_MERGE_DECISION_RECOVERY,
|
||||||
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||||
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
||||||
|
# #702 crash-orphan evidence (must outlive TTL; F4)
|
||||||
|
KIND_REVIEWER_SESSION_LEASE,
|
||||||
|
KIND_STALE_BINDING_RECOVERY,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,6 +150,7 @@ def state_key(
|
|||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build durable filename key.
|
"""Build durable filename key.
|
||||||
|
|
||||||
@@ -145,17 +158,20 @@ def state_key(
|
|||||||
so the primary key is kind + profile identity. Remote/org/repo are stored
|
so the primary key is kind + profile identity. Remote/org/repo are stored
|
||||||
inside the payload and validated on load (#559), which lets a later daemon
|
inside the payload and validated on load (#559), which lets a later daemon
|
||||||
process recover state without already knowing the remote argument.
|
process recover state without already knowing the remote argument.
|
||||||
|
|
||||||
|
*instance_id* extends the key for kinds where one-per-profile collides —
|
||||||
|
concurrent same-profile sessions each own their reviewer-lease shadow
|
||||||
|
(#702 F5), keyed by their lease session id so a later heartbeat can never
|
||||||
|
overwrite or misattribute another session's crash evidence.
|
||||||
"""
|
"""
|
||||||
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
||||||
# intentionally not part of the filename for session-scoped proofs.
|
# intentionally not part of the filename for session-scoped proofs.
|
||||||
_ = (remote, org, repo)
|
_ = (remote, org, repo)
|
||||||
return "-".join(
|
parts = [kind, profile_identity or "unknown-profile"]
|
||||||
_sanitize_segment(part)
|
instance = (instance_id or "").strip()
|
||||||
for part in (
|
if instance:
|
||||||
kind,
|
parts.append(instance)
|
||||||
profile_identity or "unknown-profile",
|
return "-".join(_sanitize_segment(part) for part in parts)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def state_file_path(
|
def state_file_path(
|
||||||
@@ -166,6 +182,7 @@ def state_file_path(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
root = (state_dir or default_state_dir()).strip()
|
root = (state_dir or default_state_dir()).strip()
|
||||||
name = state_key(
|
name = state_key(
|
||||||
@@ -174,6 +191,7 @@ def state_file_path(
|
|||||||
org=org,
|
org=org,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
profile_identity=profile_identity,
|
profile_identity=profile_identity,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
return os.path.join(root, f"{name}.json")
|
return os.path.join(root, f"{name}.json")
|
||||||
|
|
||||||
@@ -430,6 +448,7 @@ def load_state(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Load durable state payload when identity checks pass."""
|
"""Load durable state payload when identity checks pass."""
|
||||||
profile = current_profile_identity(profile_identity=profile_identity)
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
@@ -440,6 +459,7 @@ def load_state(
|
|||||||
repo=repo,
|
repo=repo,
|
||||||
profile_identity=profile,
|
profile_identity=profile,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
lock_path = f"{path}.lock"
|
lock_path = f"{path}.lock"
|
||||||
with _exclusive_file_lock(lock_path):
|
with _exclusive_file_lock(lock_path):
|
||||||
@@ -485,6 +505,7 @@ def save_state(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Persist or clear durable state for the given session identity key."""
|
"""Persist or clear durable state for the given session identity key."""
|
||||||
profile = current_profile_identity(
|
profile = current_profile_identity(
|
||||||
@@ -497,6 +518,11 @@ def save_state(
|
|||||||
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
||||||
key_org = org if org is not None else (payload or {}).get("org")
|
key_org = org if org is not None else (payload or {}).get("org")
|
||||||
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
||||||
|
key_instance = (
|
||||||
|
(instance_id or "").strip()
|
||||||
|
or str((payload or {}).get("instance_id") or "").strip()
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
root = _ensure_state_dir(state_dir)
|
root = _ensure_state_dir(state_dir)
|
||||||
path = state_file_path(
|
path = state_file_path(
|
||||||
@@ -506,6 +532,7 @@ def save_state(
|
|||||||
repo=key_repo,
|
repo=key_repo,
|
||||||
profile_identity=profile,
|
profile_identity=profile,
|
||||||
state_dir=root,
|
state_dir=root,
|
||||||
|
instance_id=key_instance,
|
||||||
)
|
)
|
||||||
lock_path = f"{path}.lock"
|
lock_path = f"{path}.lock"
|
||||||
with _exclusive_file_lock(lock_path):
|
with _exclusive_file_lock(lock_path):
|
||||||
@@ -535,6 +562,8 @@ def save_state(
|
|||||||
body["repo"] = key_repo
|
body["repo"] = key_repo
|
||||||
# Stamp session-state authority used for this write (#695 AC2 / AC6).
|
# Stamp session-state authority used for this write (#695 AC2 / AC6).
|
||||||
body.setdefault("session_state_dir", root)
|
body.setdefault("session_state_dir", root)
|
||||||
|
if key_instance:
|
||||||
|
body["instance_id"] = key_instance
|
||||||
try:
|
try:
|
||||||
import mcp_daemon_guard
|
import mcp_daemon_guard
|
||||||
|
|
||||||
@@ -568,6 +597,8 @@ def save_state(
|
|||||||
"transport": body.get("transport"),
|
"transport": body.get("transport"),
|
||||||
"payload": body,
|
"payload": body,
|
||||||
}
|
}
|
||||||
|
if key_instance:
|
||||||
|
envelope["instance_id"] = key_instance
|
||||||
_write_json(path, envelope)
|
_write_json(path, envelope)
|
||||||
return dict(body)
|
return dict(body)
|
||||||
|
|
||||||
@@ -580,6 +611,7 @@ def clear_state(
|
|||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
profile_identity: str | None = None,
|
profile_identity: str | None = None,
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
|
instance_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
save_state(
|
save_state(
|
||||||
kind=kind,
|
kind=kind,
|
||||||
@@ -589,8 +621,59 @@ def clear_state(
|
|||||||
repo=repo,
|
repo=repo,
|
||||||
profile_identity=profile_identity,
|
profile_identity=profile_identity,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_states(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
profile_identity: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Enumerate durable records of *kind* across all instance keys.
|
||||||
|
|
||||||
|
Crash recovery cannot know which session id left a shadow behind, so it
|
||||||
|
must be able to enumerate every record of a kind (#702 F5). Identity
|
||||||
|
checks (profile / TTL policy) apply per record exactly as in
|
||||||
|
:func:`load_state`; records that fail closed are omitted.
|
||||||
|
"""
|
||||||
|
root = (state_dir or default_state_dir()).strip()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(root))
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
for name in names:
|
||||||
|
if not name.endswith(".json") or name.startswith("."):
|
||||||
|
continue
|
||||||
|
envelope = _read_json(os.path.join(root, name))
|
||||||
|
if not envelope or envelope.get("kind") != kind:
|
||||||
|
continue
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
merged = dict(payload)
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"profile_identity",
|
||||||
|
"session_profile_lock",
|
||||||
|
"recorded_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer_pid",
|
||||||
|
):
|
||||||
|
if key in envelope and key not in merged:
|
||||||
|
merged[key] = envelope[key]
|
||||||
|
if identity_match_reasons(merged, profile_identity=profile):
|
||||||
|
continue
|
||||||
|
results.append(merged)
|
||||||
|
return results
|
||||||
|
|
||||||
def list_decision_lock_profile_identities(
|
def list_decision_lock_profile_identities(
|
||||||
state_dir: str | None = None,
|
state_dir: str | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
|||||||
|
|
||||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||||
|
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||||
|
|
||||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||||
SOURCE_ADOPT,
|
SOURCE_ADOPT,
|
||||||
SOURCE_ACQUIRE,
|
SOURCE_ACQUIRE,
|
||||||
|
SOURCE_ACQUIRE_MERGER,
|
||||||
SOURCE_HEARTBEAT,
|
SOURCE_HEARTBEAT,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,8 +211,10 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
|||||||
r"(?is)\b("
|
r"(?is)\b("
|
||||||
r"gitea_adopt_merger_pr_lease|"
|
r"gitea_adopt_merger_pr_lease|"
|
||||||
r"gitea_acquire_reviewer_pr_lease|"
|
r"gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"gitea_acquire_merger_pr_lease|"
|
||||||
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||||
|
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
|
||||||
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||||
r"adoption_comment_id\s*[:=]|"
|
r"adoption_comment_id\s*[:=]|"
|
||||||
|
|||||||
@@ -56,23 +56,46 @@ def resolve_namespace_workspace(
|
|||||||
env: dict[str, str] | os._Environ | None = None,
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
session_lease_worktree: str | None = None,
|
session_lease_worktree: str | None = None,
|
||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
|
demotions: list[str] | None = None,
|
||||||
|
verify_paths: bool = False,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
||||||
|
|
||||||
|
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||||
|
are demoted (#702): a binding to a deleted worktree can never name a
|
||||||
|
valid task workspace, so resolution falls through to the next candidate.
|
||||||
|
Explicit arguments are never demoted — a caller-declared path must fail
|
||||||
|
loudly downstream rather than silently rebind. Demotion notes are
|
||||||
|
appended to *demotions* when provided. Runtime-context and mutation
|
||||||
|
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||||
|
always verifies.
|
||||||
|
"""
|
||||||
env_map = env if env is not None else os.environ
|
env_map = env if env is not None else os.environ
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||||
|
|
||||||
for candidate, source in (
|
for candidate, source, env_sourced in (
|
||||||
(worktree_path, "worktree_path argument"),
|
(worktree_path, "worktree_path argument", False),
|
||||||
(worktree, "worktree argument"),
|
(worktree, "worktree argument", False),
|
||||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||||
|
(_env_value(env_map, role_env_key),
|
||||||
|
f"{role_env_key} environment variable", True),
|
||||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||||
"reviewer PR lease worktree"),
|
"reviewer PR lease worktree", False),
|
||||||
):
|
):
|
||||||
text = (candidate or "").strip()
|
text = (candidate or "").strip()
|
||||||
if text:
|
if not text:
|
||||||
return os.path.realpath(os.path.abspath(text)), source
|
continue
|
||||||
|
real = os.path.realpath(os.path.abspath(text))
|
||||||
|
if verify_paths and env_sourced and not os.path.isdir(real):
|
||||||
|
if demotions is not None:
|
||||||
|
demotions.append(
|
||||||
|
f"{source} '{real}' demoted: path no longer exists "
|
||||||
|
"(stale binding, #702)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return real, source
|
||||||
|
|
||||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||||
|
|
||||||
@@ -88,6 +111,7 @@ def resolve_namespace_mutation_context(
|
|||||||
profile_name: str | None = None,
|
profile_name: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||||
|
demotions: list[str] = []
|
||||||
workspace, binding_source = resolve_namespace_workspace(
|
workspace, binding_source = resolve_namespace_workspace(
|
||||||
role_kind=role_kind,
|
role_kind=role_kind,
|
||||||
worktree_path=worktree_path,
|
worktree_path=worktree_path,
|
||||||
@@ -96,6 +120,8 @@ def resolve_namespace_mutation_context(
|
|||||||
env=env,
|
env=env,
|
||||||
session_lease_worktree=session_lease_worktree,
|
session_lease_worktree=session_lease_worktree,
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
|
demotions=demotions,
|
||||||
|
verify_paths=True,
|
||||||
)
|
)
|
||||||
process_root = os.path.realpath(process_project_root)
|
process_root = os.path.realpath(process_project_root)
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
@@ -111,7 +137,7 @@ def resolve_namespace_mutation_context(
|
|||||||
"workspace_path": workspace,
|
"workspace_path": workspace,
|
||||||
"workspace_binding_source": binding_source,
|
"workspace_binding_source": binding_source,
|
||||||
"workspace_role_kind": role,
|
"workspace_role_kind": role,
|
||||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
"ignored_bindings": demotions + (pollution.get("ignored_bindings") or []),
|
||||||
"process_project_root": process_root,
|
"process_project_root": process_root,
|
||||||
"canonical_repo_root": canonical_root,
|
"canonical_repo_root": canonical_root,
|
||||||
"roots_aligned": canonical_root == process_root,
|
"roots_aligned": canonical_root == process_root,
|
||||||
|
|||||||
@@ -407,18 +407,194 @@ def record_session_lease(
|
|||||||
if lease_provenance:
|
if lease_provenance:
|
||||||
stored["lease_provenance"] = dict(lease_provenance)
|
stored["lease_provenance"] = dict(lease_provenance)
|
||||||
_SESSION_LEASE = stored
|
_SESSION_LEASE = stored
|
||||||
|
_persist_session_lease_shadow(stored)
|
||||||
return dict(_SESSION_LEASE)
|
return dict(_SESSION_LEASE)
|
||||||
|
|
||||||
|
|
||||||
def clear_session_lease() -> None:
|
def clear_session_lease() -> None:
|
||||||
global _SESSION_LEASE
|
global _SESSION_LEASE
|
||||||
|
prior = _SESSION_LEASE
|
||||||
_SESSION_LEASE = None
|
_SESSION_LEASE = None
|
||||||
|
_persist_session_lease_shadow(None, prior=prior)
|
||||||
|
|
||||||
|
|
||||||
def get_session_lease() -> dict[str, Any] | None:
|
def get_session_lease() -> dict[str, Any] | None:
|
||||||
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_session_lease_shadow(
|
||||||
|
lease: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
prior: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Best-effort durable shadow of the in-session lease (#702).
|
||||||
|
|
||||||
|
A daemon that dies without teardown leaves this record behind, giving a
|
||||||
|
later process provable orphan evidence (owner pid + session id) for the
|
||||||
|
guarded cleanup path. Observability only — mutation gates never read it,
|
||||||
|
and failures here must never block lease operations.
|
||||||
|
|
||||||
|
Shadows are keyed by lease session id (#702 F5): concurrent same-profile
|
||||||
|
sessions each own a distinct record, so one session's heartbeat can never
|
||||||
|
overwrite or misattribute another's crash evidence. A sanctioned clear is
|
||||||
|
the terminal reconciliation of that record's lifecycle.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import mcp_session_state as mss
|
||||||
|
|
||||||
|
if lease is None:
|
||||||
|
sid = ((prior or {}).get("session_id") or "").strip() or None
|
||||||
|
if sid:
|
||||||
|
mss.clear_state(
|
||||||
|
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
|
||||||
|
)
|
||||||
|
# Legacy single-slot record from pre-F5 code: clearing it is safe
|
||||||
|
# because collision-safe writes never target that key again.
|
||||||
|
mss.clear_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||||
|
return
|
||||||
|
mss.save_state(
|
||||||
|
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||||
|
instance_id=((lease.get("session_id") or "").strip() or None),
|
||||||
|
payload={
|
||||||
|
"pr_number": lease.get("pr_number"),
|
||||||
|
"session_id": lease.get("session_id"),
|
||||||
|
"comment_id": lease.get("comment_id"),
|
||||||
|
"candidate_head": lease.get("candidate_head"),
|
||||||
|
"worktree": lease.get("worktree"),
|
||||||
|
"phase": lease.get("phase"),
|
||||||
|
"reviewer_identity": lease.get("reviewer_identity"),
|
||||||
|
"profile": lease.get("profile"),
|
||||||
|
"lease_repo": lease.get("repo"),
|
||||||
|
"owner_pid": os.getpid(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_session_lease_shadow(
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Load the durable session-lease shadow left by this profile identity.
|
||||||
|
|
||||||
|
With *session_id*, load that session's collision-safe record (#702 F5),
|
||||||
|
falling back to the legacy single-slot record only when its payload names
|
||||||
|
the same session. Without *session_id*, return the legacy record or the
|
||||||
|
sole surviving instance record — ambiguity (multiple candidates) returns
|
||||||
|
``None`` because a shadow that cannot be attributed proves nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import mcp_session_state as mss
|
||||||
|
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
if sid:
|
||||||
|
record = mss.load_state(
|
||||||
|
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid
|
||||||
|
)
|
||||||
|
if record is not None:
|
||||||
|
return record
|
||||||
|
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||||
|
if legacy and (legacy.get("session_id") or "").strip() == sid:
|
||||||
|
return legacy
|
||||||
|
return None
|
||||||
|
legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||||
|
if legacy is not None:
|
||||||
|
return legacy
|
||||||
|
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||||
|
if len(records) == 1:
|
||||||
|
return records[0]
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pid_alive(pid: int) -> bool:
|
||||||
|
"""Probe process existence; fail closed toward 'alive' when unsure."""
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def assess_crashed_session_lease_orphan(
|
||||||
|
shadow: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
lease: dict[str, Any] | None,
|
||||||
|
current_pid: int | None = None,
|
||||||
|
pid_alive: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Derive owner-process evidence for a durable lease from the shadow (#702).
|
||||||
|
|
||||||
|
Returns ``owner_process_alive`` tri-state. Only a provably dead recorded
|
||||||
|
owner pid yields ``False`` (a dead pid cannot still be running the owning
|
||||||
|
daemon); an alive pid is *not* ownership proof and stays ``None`` unless
|
||||||
|
it is this very process. Session ids must match exactly — the shadow of a
|
||||||
|
different lease proves nothing.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not shadow or not lease:
|
||||||
|
return {
|
||||||
|
"orphan_evidence": False,
|
||||||
|
"owner_process_alive": None,
|
||||||
|
"reasons": ["no durable session-lease shadow evidence available"],
|
||||||
|
}
|
||||||
|
shadow_sid = (shadow.get("session_id") or "").strip()
|
||||||
|
lease_sid = (lease.get("session_id") or "").strip()
|
||||||
|
if not shadow_sid or not lease_sid or shadow_sid != lease_sid:
|
||||||
|
return {
|
||||||
|
"orphan_evidence": False,
|
||||||
|
"owner_process_alive": None,
|
||||||
|
"reasons": [
|
||||||
|
"session-lease shadow session_id does not match the durable "
|
||||||
|
f"lease (shadow={shadow_sid or None}, lease={lease_sid or None})"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
owner_pid = shadow.get("owner_pid") or shadow.get("writer_pid")
|
||||||
|
try:
|
||||||
|
owner_pid = int(owner_pid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {
|
||||||
|
"orphan_evidence": False,
|
||||||
|
"owner_process_alive": None,
|
||||||
|
"reasons": ["session-lease shadow has no usable owner pid (fail closed)"],
|
||||||
|
}
|
||||||
|
this_pid = current_pid if current_pid is not None else os.getpid()
|
||||||
|
if owner_pid == this_pid:
|
||||||
|
return {
|
||||||
|
"orphan_evidence": False,
|
||||||
|
"owner_process_alive": True,
|
||||||
|
"owner_pid": owner_pid,
|
||||||
|
"reasons": ["shadow owner pid is the current process"],
|
||||||
|
}
|
||||||
|
alive = pid_alive if pid_alive is not None else _pid_alive(owner_pid)
|
||||||
|
if alive:
|
||||||
|
reasons.append(
|
||||||
|
f"shadow owner pid {owner_pid} observed alive; PID liveness is not "
|
||||||
|
"ownership proof — owner evidence stays unknown (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"orphan_evidence": False,
|
||||||
|
"owner_process_alive": None,
|
||||||
|
"owner_pid": owner_pid,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
reasons.append(
|
||||||
|
f"shadow owner pid {owner_pid} is dead: the daemon that recorded the "
|
||||||
|
"matching session lease exited without teardown (crash orphan, #702)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"orphan_evidence": True,
|
||||||
|
"owner_process_alive": False,
|
||||||
|
"owner_pid": owner_pid,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def assess_mutation_lease_gate(
|
def assess_mutation_lease_gate(
|
||||||
*,
|
*,
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@@ -557,6 +733,7 @@ _HANDOFF_CLASSIFICATIONS = frozenset({
|
|||||||
"foreign_completed_superseded_head",
|
"foreign_completed_superseded_head",
|
||||||
"foreign_expired_superseded_head",
|
"foreign_expired_superseded_head",
|
||||||
"orphaned_owner_missing",
|
"orphaned_owner_missing",
|
||||||
|
"orphaned_expired_superseded_head",
|
||||||
"ambiguous_conflicting_evidence",
|
"ambiguous_conflicting_evidence",
|
||||||
"instructed_lease_missing_with_replacement",
|
"instructed_lease_missing_with_replacement",
|
||||||
"worktree_binding_mismatch",
|
"worktree_binding_mismatch",
|
||||||
@@ -850,6 +1027,38 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
|||||||
classification = "foreign_expired_superseded_head"
|
classification = "foreign_expired_superseded_head"
|
||||||
elif head_superseded and has_terminal and not past_expiry:
|
elif head_superseded and has_terminal and not past_expiry:
|
||||||
classification = "foreign_completed_superseded_head"
|
classification = "foreign_completed_superseded_head"
|
||||||
|
elif head_superseded and not has_terminal and past_expiry:
|
||||||
|
# Crash-orphan shape (#702): the lease outlived its head with no
|
||||||
|
# formal terminal review and has now passed canonical expiry, so
|
||||||
|
# it no longer gates fresh acquisition. Guarded cleanup of the
|
||||||
|
# ledger marker still demands provable owner-process-exit
|
||||||
|
# evidence and a safe worktree — never inferred.
|
||||||
|
classification = "orphaned_expired_superseded_head"
|
||||||
|
if owner_process_alive is True:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"lease past expiry on superseded head but owner process "
|
||||||
|
"still alive; cleanup denied"
|
||||||
|
)
|
||||||
|
elif owner_process_alive is None:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"expired superseded-head lease with no terminal review: "
|
||||||
|
"cleanup requires provable owner-process-exit evidence "
|
||||||
|
"(owner_process_alive=false); the expired lease no longer "
|
||||||
|
"gates fresh acquisition"
|
||||||
|
)
|
||||||
|
elif worktree_clean is False:
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"owner process absent but worktree dirty; cleanup denied"
|
||||||
|
)
|
||||||
|
elif not (worktree_clean is True or worktree_exists is False):
|
||||||
|
# #702 F3: unknown worktree evidence must fail closed, exactly
|
||||||
|
# like the sibling orphaned_owner_missing gate and the
|
||||||
|
# diagnosis path — cleanup needs affirmative safe evidence.
|
||||||
|
fail_closed_reasons.append(
|
||||||
|
"owner process absent but worktree evidence is unknown; "
|
||||||
|
"cleanup requires affirmative safe evidence "
|
||||||
|
"(worktree_clean=true or worktree_exists=false)"
|
||||||
|
)
|
||||||
elif head_superseded and not has_terminal:
|
elif head_superseded and not has_terminal:
|
||||||
classification = "ambiguous_conflicting_evidence"
|
classification = "ambiguous_conflicting_evidence"
|
||||||
fail_closed_reasons.append(
|
fail_closed_reasons.append(
|
||||||
@@ -891,6 +1100,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
|||||||
"foreign_expired_superseded_head",
|
"foreign_expired_superseded_head",
|
||||||
"foreign_expired_current_head",
|
"foreign_expired_current_head",
|
||||||
"orphaned_owner_missing",
|
"orphaned_owner_missing",
|
||||||
|
"orphaned_expired_superseded_head",
|
||||||
}:
|
}:
|
||||||
fail_closed_reasons.append(
|
fail_closed_reasons.append(
|
||||||
"controller/recovery capability required "
|
"controller/recovery capability required "
|
||||||
@@ -909,6 +1119,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup(
|
|||||||
"foreign_completed_superseded_head",
|
"foreign_completed_superseded_head",
|
||||||
"foreign_expired_superseded_head",
|
"foreign_expired_superseded_head",
|
||||||
"orphaned_owner_missing",
|
"orphaned_owner_missing",
|
||||||
|
"orphaned_expired_superseded_head",
|
||||||
"foreign_expired_current_head",
|
"foreign_expired_current_head",
|
||||||
}
|
}
|
||||||
# foreign_expired_current_head needs orphan/clean worktree + authority.
|
# foreign_expired_current_head needs orphan/clean worktree + authority.
|
||||||
@@ -1181,6 +1392,30 @@ def diagnose_reviewer_pr_lease_handoff(
|
|||||||
"foreign lease pinned to superseded head with completed formal "
|
"foreign lease pinned to superseded head with completed formal "
|
||||||
"review; use guarded obsolete-lease cleanup (do not wait indefinitely)"
|
"review; use guarded obsolete-lease cleanup (do not wait indefinitely)"
|
||||||
)
|
)
|
||||||
|
elif not is_own and head_superseded and not terminal and past_expiry:
|
||||||
|
# Crash-orphan shape after canonical expiry (#702): the expired
|
||||||
|
# marker no longer gates acquisition, so a fresh reviewer may
|
||||||
|
# acquire at the current head. Guarded ledger cleanup becomes
|
||||||
|
# available only with provable owner-exit evidence.
|
||||||
|
classification = "orphaned_expired_superseded_head"
|
||||||
|
if owner_process_alive is False and (
|
||||||
|
worktree_clean is True or worktree_exists is False
|
||||||
|
):
|
||||||
|
next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE
|
||||||
|
reasons.append(
|
||||||
|
"expired superseded-head lease with no terminal review and "
|
||||||
|
"provable owner-process exit (crash orphan, #702); guarded "
|
||||||
|
"obsolete-lease cleanup eligible"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
next_action = NEXT_ACTION_ACQUIRE
|
||||||
|
reasons.append(
|
||||||
|
"lease head superseded and lease past canonical expiry with "
|
||||||
|
"no formal terminal review (crash orphan, #702); expired "
|
||||||
|
"marker no longer gates acquisition — acquire a fresh lease "
|
||||||
|
"at the current head; guarded cleanup needs "
|
||||||
|
"owner_process_alive=false and a clean/absent worktree"
|
||||||
|
)
|
||||||
elif not is_own and head_superseded and not terminal:
|
elif not is_own and head_superseded and not terminal:
|
||||||
classification = "ambiguous_conflicting_evidence"
|
classification = "ambiguous_conflicting_evidence"
|
||||||
next_action = NEXT_ACTION_WAIT
|
next_action = NEXT_ACTION_WAIT
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
"""Session-immutable MCP mutation context (#714).
|
||||||
|
|
||||||
|
Pins profile, remote, host, repository, identity, and role for the life of an
|
||||||
|
MCP process (or until an explicit ``gitea_activate_profile`` re-bind).
|
||||||
|
|
||||||
|
Capability resolution and mutation gates must evaluate only the active
|
||||||
|
profile for the requested remote. Silent cross-host / cross-profile
|
||||||
|
substitution is forbidden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SessionContext:
|
||||||
|
"""One atomic, immutable process-session binding."""
|
||||||
|
|
||||||
|
profile_name: str | None
|
||||||
|
remote: str | None
|
||||||
|
host: str | None
|
||||||
|
identity: str | None
|
||||||
|
repository: str | None
|
||||||
|
org: str | None
|
||||||
|
role_kind: str | None
|
||||||
|
expected_username: str | None
|
||||||
|
source: str
|
||||||
|
pid: int
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"profile_name": self.profile_name,
|
||||||
|
"remote": self.remote,
|
||||||
|
"host": self.host,
|
||||||
|
"identity": self.identity,
|
||||||
|
"repository": self.repository,
|
||||||
|
"org": self.org,
|
||||||
|
"role_kind": self.role_kind,
|
||||||
|
"expected_username": self.expected_username,
|
||||||
|
"source": self.source,
|
||||||
|
"pid": self.pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||||
|
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||||
|
# sanctioned rebind atomic across concurrent MCP calls.
|
||||||
|
_SESSION_CONTEXT: _SessionContext | None = None
|
||||||
|
_SESSION_CONTEXT_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_session_context_for_testing() -> None:
|
||||||
|
"""Reset at a pytest test boundary; unavailable to production callers.
|
||||||
|
|
||||||
|
Production sessions transition only through process startup/PID change or
|
||||||
|
the explicit profile-activation rebind. Keeping this helper private and
|
||||||
|
requiring pytest's per-test marker prevents it from becoming an MCP/runtime
|
||||||
|
bypass.
|
||||||
|
"""
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||||
|
raise RuntimeError("session context reset is restricted to pytest boundaries")
|
||||||
|
global _SESSION_CONTEXT
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
_SESSION_CONTEXT = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_context() -> dict[str, Any] | None:
|
||||||
|
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
if _SESSION_CONTEXT is None:
|
||||||
|
return None
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def profile_host(profile: dict | None) -> str | None:
|
||||||
|
"""Hostname from profile base_url, lowercased, or None."""
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
base = (profile.get("base_url") or "").strip()
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(base)
|
||||||
|
host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||||
|
return host or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def remote_host(remote: str | None, remotes: dict | None) -> str | None:
|
||||||
|
"""Hostname for a known remote key."""
|
||||||
|
if not remote or not remotes:
|
||||||
|
return None
|
||||||
|
entry = remotes.get(remote) or {}
|
||||||
|
return (entry.get("host") or "").strip().lower() or None
|
||||||
|
|
||||||
|
|
||||||
|
def profile_matches_remote(
|
||||||
|
profile: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
*,
|
||||||
|
contexts: dict | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when *profile* is bound to the same host/context as *remote*."""
|
||||||
|
if not profile or not remote:
|
||||||
|
return False
|
||||||
|
r_host = remote_host(remote, remotes)
|
||||||
|
p_host = profile_host(profile)
|
||||||
|
if r_host and p_host:
|
||||||
|
return r_host == p_host
|
||||||
|
# Fall back to context name heuristics when base_url missing.
|
||||||
|
ctx = (profile.get("context") or "").strip().lower()
|
||||||
|
if not ctx or not contexts:
|
||||||
|
return False
|
||||||
|
ctx_data = contexts.get(ctx) or {}
|
||||||
|
gitea = ctx_data.get("gitea") or {}
|
||||||
|
base = (gitea.get("base_url") or "").strip()
|
||||||
|
if not base or not r_host:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(base)
|
||||||
|
c_host = (parsed.netloc or parsed.path or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return bool(c_host) and c_host == r_host
|
||||||
|
|
||||||
|
|
||||||
|
def bind_session_context(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
source: str = "bind",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
return _bind_session_context_unlocked(
|
||||||
|
profile_name=profile_name,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
identity=identity,
|
||||||
|
repository=repository,
|
||||||
|
org=org,
|
||||||
|
role_kind=role_kind,
|
||||||
|
expected_username=expected_username,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_session_context_unlocked(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None,
|
||||||
|
org: str | None,
|
||||||
|
role_kind: str | None,
|
||||||
|
expected_username: str | None,
|
||||||
|
source: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Store a complete immutable context while the caller holds the lock."""
|
||||||
|
global _SESSION_CONTEXT
|
||||||
|
_SESSION_CONTEXT = _SessionContext(
|
||||||
|
profile_name=(profile_name or "").strip() or None,
|
||||||
|
remote=(remote or "").strip() or None,
|
||||||
|
host=(host or "").strip().lower() or None,
|
||||||
|
identity=(identity or "").strip() or None,
|
||||||
|
repository=(repository or "").strip() or None,
|
||||||
|
org=(org or "").strip() or None,
|
||||||
|
role_kind=(role_kind or "").strip() or None,
|
||||||
|
expected_username=(expected_username or "").strip() or None,
|
||||||
|
source=source,
|
||||||
|
pid=os.getpid(),
|
||||||
|
)
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_session_context_if_unbound(
|
||||||
|
*,
|
||||||
|
profile_name: str,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None,
|
||||||
|
identity: str | None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
source: str = "seed",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Atomically bind only when this process has no current context.
|
||||||
|
|
||||||
|
A changed environment or an interleaved call is not a session boundary and
|
||||||
|
therefore cannot replace an established binding. A newly started/forked
|
||||||
|
process is recognized by PID; explicit ``gitea_activate_profile`` uses
|
||||||
|
:func:`bind_session_context` as its sanctioned logical-session transition.
|
||||||
|
"""
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
if _SESSION_CONTEXT is None or _SESSION_CONTEXT.pid != os.getpid():
|
||||||
|
return _bind_session_context_unlocked(
|
||||||
|
profile_name=profile_name,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
identity=identity,
|
||||||
|
repository=repository,
|
||||||
|
org=org,
|
||||||
|
role_kind=role_kind,
|
||||||
|
expected_username=expected_username,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
return _SESSION_CONTEXT.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def assess_session_context(
|
||||||
|
*,
|
||||||
|
profile_name: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
host: str | None = None,
|
||||||
|
identity: str | None = None,
|
||||||
|
repository: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
expected_username: str | None = None,
|
||||||
|
require_bound: bool = False,
|
||||||
|
require_complete: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compare live values against the bound session context.
|
||||||
|
|
||||||
|
Returns ``proven`` / ``block`` / ``reasons``. When unbound and
|
||||||
|
``require_bound`` is false, does not block (caller may seed). When
|
||||||
|
unbound and ``require_bound`` is true, fails closed. ``require_complete``
|
||||||
|
additionally fails closed when the binding carries no verified
|
||||||
|
repository/organization identity, so a mutation can never run against an
|
||||||
|
unknown repository (#714).
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
with _SESSION_CONTEXT_LOCK:
|
||||||
|
bound = _SESSION_CONTEXT
|
||||||
|
ctx = bound.as_dict() if bound is not None else None
|
||||||
|
if ctx is None or ctx.get("pid") != os.getpid():
|
||||||
|
if require_bound:
|
||||||
|
reasons.append(
|
||||||
|
"session mutation context is unbound; call gitea_whoami or "
|
||||||
|
"gitea_activate_profile before mutating (fail closed)"
|
||||||
|
)
|
||||||
|
return _assessment(False, reasons, ctx)
|
||||||
|
return _assessment(True, reasons, ctx)
|
||||||
|
|
||||||
|
if require_complete and not (ctx.get("repository") and ctx.get("org")):
|
||||||
|
reasons.append(
|
||||||
|
"session repository/organization identity is unverified "
|
||||||
|
f"(repository={ctx.get('repository')!r}, org={ctx.get('org')!r}); "
|
||||||
|
"a mutation cannot proceed without a verified workspace "
|
||||||
|
"repository (fail closed)"
|
||||||
|
)
|
||||||
|
return _assessment(False, reasons, ctx)
|
||||||
|
|
||||||
|
live_profile = (profile_name or "").strip() or None
|
||||||
|
live_remote = (remote or "").strip() or None
|
||||||
|
live_host = (host or "").strip().lower() or None
|
||||||
|
live_identity = (identity or "").strip() or None
|
||||||
|
live_repo = (repository or "").strip() or None
|
||||||
|
live_org = (org or "").strip() or None
|
||||||
|
|
||||||
|
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
|
||||||
|
reasons.append(
|
||||||
|
f"profile drift: live '{live_profile}' != bound "
|
||||||
|
f"'{ctx.get('profile_name')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("remote") and live_remote and live_remote != ctx.get("remote"):
|
||||||
|
reasons.append(
|
||||||
|
f"remote drift: live '{live_remote}' != bound "
|
||||||
|
f"'{ctx.get('remote')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("host") and live_host and live_host != ctx.get("host"):
|
||||||
|
reasons.append(
|
||||||
|
f"host drift: live '{live_host}' != bound "
|
||||||
|
f"'{ctx.get('host')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
ctx.get("identity")
|
||||||
|
and live_identity
|
||||||
|
and live_identity != ctx.get("identity")
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
f"identity drift: live '{live_identity}' != bound "
|
||||||
|
f"'{ctx.get('identity')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("repository") and live_repo and live_repo != ctx.get("repository"):
|
||||||
|
reasons.append(
|
||||||
|
f"repository drift: live '{live_repo}' != bound "
|
||||||
|
f"'{ctx.get('repository')}' (fail closed)"
|
||||||
|
)
|
||||||
|
if ctx.get("org") and live_org and live_org != ctx.get("org"):
|
||||||
|
reasons.append(
|
||||||
|
f"org drift: live '{live_org}' != bound "
|
||||||
|
f"'{ctx.get('org')}' (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = expected_username or ctx.get("expected_username")
|
||||||
|
if expected and live_identity and live_identity != expected:
|
||||||
|
reasons.append(
|
||||||
|
f"identity mismatch: authenticated '{live_identity}' != "
|
||||||
|
f"profile expected '{expected}' (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _assessment(not reasons, reasons, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_identity_match(
|
||||||
|
*,
|
||||||
|
authenticated: str | None,
|
||||||
|
expected_username: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail closed when profile declares a username that does not match live auth."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
auth = (authenticated or "").strip() or None
|
||||||
|
expected = (expected_username or "").strip() or None
|
||||||
|
if expected and auth and auth != expected:
|
||||||
|
reasons.append(
|
||||||
|
f"identity mismatch: authenticated '{auth}' != "
|
||||||
|
f"profile expected '{expected}' (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"reasons": reasons,
|
||||||
|
"authenticated": auth,
|
||||||
|
"expected_username": expected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_SLUG_RE = re.compile(r"^\s*(?P<org>[^/\s]+)\s*/\s*(?P<repo>[^/\s]+?)(?:\.git)?\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_repository_slug(slug: str | None) -> tuple[str, str] | None:
|
||||||
|
"""Split a canonical ``owner/repository`` slug, or None when unparseable."""
|
||||||
|
match = _REPO_SLUG_RE.match(slug or "")
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group("org"), match.group("repo")
|
||||||
|
|
||||||
|
|
||||||
|
def format_repository_slug(org: str | None, repo: str | None) -> str | None:
|
||||||
|
"""``owner/repository`` from parts, or None when either side is missing."""
|
||||||
|
left = (org or "").strip()
|
||||||
|
right = (repo or "").strip()
|
||||||
|
if not left or not right:
|
||||||
|
return None
|
||||||
|
return f"{left}/{right}"
|
||||||
|
|
||||||
|
|
||||||
|
def declared_allowed_repositories(
|
||||||
|
profile: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
strict: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Canonical ``owner/repository`` authorization boundary declared by *profile*.
|
||||||
|
|
||||||
|
This list is an authorization boundary, never the session binding itself:
|
||||||
|
the verified workspace selects exactly one entry (see
|
||||||
|
:func:`assess_repository_scope`).
|
||||||
|
|
||||||
|
When *strict* is true (mutation path), missing profile, non-list values, or
|
||||||
|
any malformed entry raise ``ValueError`` instead of silently collapsing to
|
||||||
|
an empty scope (which would fail open). Read-only diagnostics may use
|
||||||
|
strict=False.
|
||||||
|
"""
|
||||||
|
if not profile:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(
|
||||||
|
"profile unresolved; cannot declare allowed_repositories "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
raw = profile.get("allowed_repositories")
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, (list, tuple)):
|
||||||
|
if strict:
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_repositories must be a list of owner/repository slugs "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
slugs: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
errors.append(f"non-string allowed_repositories entry {entry!r}")
|
||||||
|
continue
|
||||||
|
parsed = parse_repository_slug(entry)
|
||||||
|
if not parsed:
|
||||||
|
errors.append(
|
||||||
|
f"malformed allowed_repositories entry {entry!r} "
|
||||||
|
"(expected owner/repository)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
slugs.append(f"{parsed[0]}/{parsed[1]}")
|
||||||
|
if strict and errors:
|
||||||
|
raise ValueError("; ".join(errors) + " (fail closed)")
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
|
def assess_repository_scope(
|
||||||
|
*,
|
||||||
|
workspace_slug: str | None,
|
||||||
|
allowed: list[str] | None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
require_scope: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Authorize the verified workspace repository against the profile allowlist.
|
||||||
|
|
||||||
|
The workspace-derived slug is the only candidate: a profile that authorizes
|
||||||
|
several repositories still binds to the single verified one, and never to
|
||||||
|
the list as a whole.
|
||||||
|
|
||||||
|
*require_scope* (mutation path): missing/empty allowlist fails closed. The
|
||||||
|
workspace remote cannot self-authorize without a configured boundary.
|
||||||
|
"""
|
||||||
|
scope = list(allowed or [])
|
||||||
|
reasons: list[str] = []
|
||||||
|
name = profile_name or "(active profile)"
|
||||||
|
if not scope:
|
||||||
|
if require_scope:
|
||||||
|
reasons.append(
|
||||||
|
f"mutation denied: profile '{name}' has no non-empty "
|
||||||
|
"allowed_repositories authorization boundary (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": False,
|
||||||
|
"block": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": True,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": False,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": [],
|
||||||
|
}
|
||||||
|
if not workspace_slug:
|
||||||
|
reasons.append(
|
||||||
|
f"no verified workspace repository could be established for profile "
|
||||||
|
f"'{name}'; repository scope cannot be authorized (fail closed)"
|
||||||
|
)
|
||||||
|
elif workspace_slug.lower() not in {entry.lower() for entry in scope}:
|
||||||
|
reasons.append(
|
||||||
|
f"repository scope denial: workspace repository '{workspace_slug}' "
|
||||||
|
f"is not authorized by profile '{name}' allowed_repositories "
|
||||||
|
f"{sorted(scope)} (fail closed)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proven": not reasons,
|
||||||
|
"block": bool(reasons),
|
||||||
|
"reasons": reasons,
|
||||||
|
"scope_enforced": True,
|
||||||
|
"workspace_slug": workspace_slug,
|
||||||
|
"allowed_repositories": sorted(scope),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_repository_override(
|
||||||
|
*,
|
||||||
|
requested_org: str | None,
|
||||||
|
requested_repo: str | None,
|
||||||
|
bound_org: str | None,
|
||||||
|
bound_repo: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Caller-supplied org/repo must agree with the immutable binding.
|
||||||
|
|
||||||
|
A mutation request is never allowed to establish, complete, or replace the
|
||||||
|
binding — it may only be checked against it.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
req_org = (requested_org or "").strip() or None
|
||||||
|
req_repo = (requested_repo or "").strip() or None
|
||||||
|
if req_repo and bound_repo and req_repo.lower() != bound_repo.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"repository override denial: request targets '{req_repo}' but the "
|
||||||
|
f"session is bound to '{bound_repo}' (fail closed)"
|
||||||
|
)
|
||||||
|
if req_org and bound_org and req_org.lower() != bound_org.lower():
|
||||||
|
reasons.append(
|
||||||
|
f"organization override denial: request targets '{req_org}' but the "
|
||||||
|
f"session is bound to '{bound_org}' (fail closed)"
|
||||||
|
)
|
||||||
|
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def filter_profiles_for_remote(
|
||||||
|
config: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Profile names whose host/context matches *remote* (enabled only)."""
|
||||||
|
if not config or not remote:
|
||||||
|
return []
|
||||||
|
profiles = config.get("profiles") or {}
|
||||||
|
contexts = config.get("contexts") or {}
|
||||||
|
names: list[str] = []
|
||||||
|
for name, data in profiles.items():
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
if not data.get("enabled", True):
|
||||||
|
continue
|
||||||
|
if profile_matches_remote(data, remote, remotes, contexts=contexts):
|
||||||
|
names.append(name)
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_allowed_for_remote(
|
||||||
|
profile: dict | None,
|
||||||
|
remote: str | None,
|
||||||
|
remotes: dict | None,
|
||||||
|
*,
|
||||||
|
contexts: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether the active profile may serve *remote*."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not profile:
|
||||||
|
reasons.append("active profile unresolved (fail closed)")
|
||||||
|
return {"proven": False, "block": True, "reasons": reasons}
|
||||||
|
if not remote:
|
||||||
|
return {"proven": True, "block": False, "reasons": reasons}
|
||||||
|
# Legacy env-only profiles have no configured base URL or v2 context. Their
|
||||||
|
# first call may establish the process remote/host pin; after that,
|
||||||
|
# assess_session_context rejects any drift. Configured v2 profiles still
|
||||||
|
# require positive host/context alignment here.
|
||||||
|
if not profile_host(profile) and not (profile.get("context") or "").strip():
|
||||||
|
return {"proven": True, "block": False, "reasons": reasons}
|
||||||
|
if not profile_matches_remote(profile, remote, remotes, contexts=contexts):
|
||||||
|
p_name = profile.get("profile_name") or profile.get("name") or "(unknown)"
|
||||||
|
p_host = profile_host(profile) or "(none)"
|
||||||
|
r_host = remote_host(remote, remotes) or "(none)"
|
||||||
|
reasons.append(
|
||||||
|
f"cross-host profile denial: profile '{p_name}' (host '{p_host}') "
|
||||||
|
f"cannot serve remote '{remote}' (host '{r_host}') (fail closed)"
|
||||||
|
)
|
||||||
|
return {"proven": not reasons, "block": bool(reasons), "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def mutation_context_audit_fields(
|
||||||
|
ctx: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fields to include in pre-mutation audit records."""
|
||||||
|
data = ctx if ctx is not None else get_session_context()
|
||||||
|
if not data:
|
||||||
|
return {
|
||||||
|
"session_context_bound": False,
|
||||||
|
"session_profile": None,
|
||||||
|
"session_remote": None,
|
||||||
|
"session_host": None,
|
||||||
|
"session_identity": None,
|
||||||
|
"session_repository": None,
|
||||||
|
"session_org": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"session_context_bound": True,
|
||||||
|
"session_profile": data.get("profile_name"),
|
||||||
|
"session_remote": data.get("remote"),
|
||||||
|
"session_host": data.get("host"),
|
||||||
|
"session_identity": data.get("identity"),
|
||||||
|
"session_repository": data.get("repository"),
|
||||||
|
"session_org": data.get("org"),
|
||||||
|
"session_role_kind": data.get("role_kind"),
|
||||||
|
"session_context_source": data.get("source"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(
|
||||||
|
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"proven": proven,
|
||||||
|
"block": not proven,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"bound_context": dict(ctx) if ctx else None,
|
||||||
|
"audit": mutation_context_audit_fields(ctx),
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Sanctioned recovery for stale env-bound task workspace bindings (#702).
|
||||||
|
|
||||||
|
When the MCP daemon dies from an unhandled exception it never runs teardown,
|
||||||
|
and the auto-reconnected daemon inherits ``GITEA_ACTIVE_WORKTREE`` from its
|
||||||
|
parent environment. That inherited value can permanently bind the fresh
|
||||||
|
session to a prior task's worktree (the PR #701 / ``review-pr-654`` incident).
|
||||||
|
|
||||||
|
This module classifies the active env binding against live session evidence
|
||||||
|
and produces a fail-closed recovery plan. Only two situations permit the
|
||||||
|
daemon to clear the binding on its own:
|
||||||
|
|
||||||
|
- the bound path no longer exists on disk (``provably_stale_missing_path``)
|
||||||
|
- the binding was inherited at daemon boot and a *sanctioned* in-session
|
||||||
|
reviewer/merger lease later bound a different worktree
|
||||||
|
(``superseded_by_session_lease``)
|
||||||
|
|
||||||
|
Everything else is surfaced (``unverified_inherited``) and left for the
|
||||||
|
managed reconnect path — never cleared silently, never rebound to a guessed
|
||||||
|
worktree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||||
|
|
||||||
|
CLASSIFICATION_UNBOUND = "unbound"
|
||||||
|
CLASSIFICATION_CORROBORATED = "corroborated"
|
||||||
|
CLASSIFICATION_UNVERIFIED_INHERITED = "unverified_inherited"
|
||||||
|
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH = "provably_stale_missing_path"
|
||||||
|
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE = "superseded_by_session_lease"
|
||||||
|
|
||||||
|
CLASSIFICATIONS = frozenset({
|
||||||
|
CLASSIFICATION_UNBOUND,
|
||||||
|
CLASSIFICATION_CORROBORATED,
|
||||||
|
CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Roles whose task workspace is owned by a lease lifecycle, so an inherited
|
||||||
|
# generic binding without a corroborating lease is suspect.
|
||||||
|
LEASE_BOUND_ROLES = frozenset({"reviewer", "merger"})
|
||||||
|
|
||||||
|
RECOVERY_ACTION_NONE = "none"
|
||||||
|
RECOVERY_ACTION_CLEAR_ENV = "clear_env"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(path: str | None) -> str:
|
||||||
|
text = (path or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return os.path.realpath(os.path.abspath(text))
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_boot_bindings(
|
||||||
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Capture worktree env bindings present when the daemon booted.
|
||||||
|
|
||||||
|
A value present in this snapshot was inherited from the parent
|
||||||
|
environment, not established by any sanctioned tool in this daemon's
|
||||||
|
lifetime.
|
||||||
|
"""
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
|
return {
|
||||||
|
"active_worktree": (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_active_worktree_binding(
|
||||||
|
*,
|
||||||
|
active_value: str | None,
|
||||||
|
role_env_value: str | None = None,
|
||||||
|
session_lease_worktree: str | None = None,
|
||||||
|
boot_inherited: bool,
|
||||||
|
path_exists: bool | None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify the GITEA_ACTIVE_WORKTREE binding against live evidence.
|
||||||
|
|
||||||
|
Fail closed: unknown evidence never yields a clear-eligible class.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
active = _norm(active_value)
|
||||||
|
if not active:
|
||||||
|
return {
|
||||||
|
"classification": CLASSIFICATION_UNBOUND,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"active_worktree": None,
|
||||||
|
"reasons": ["no GITEA_ACTIVE_WORKTREE binding present"],
|
||||||
|
}
|
||||||
|
|
||||||
|
role = (role_kind or "").strip().lower()
|
||||||
|
role_env = _norm(role_env_value)
|
||||||
|
lease_wt = _norm(session_lease_worktree)
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"active_worktree": active,
|
||||||
|
"boot_inherited": bool(boot_inherited),
|
||||||
|
"role_kind": role or None,
|
||||||
|
"session_lease_worktree": lease_wt or None,
|
||||||
|
"role_env_worktree": role_env or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if path_exists is False:
|
||||||
|
reasons.append(
|
||||||
|
f"bound worktree '{active}' no longer exists on disk; the binding "
|
||||||
|
"cannot name a valid task workspace"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
"clear_eligible": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if lease_wt and lease_wt == active:
|
||||||
|
reasons.append("binding matches the sanctioned in-session lease worktree")
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_CORROBORATED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if lease_wt and lease_wt != active and boot_inherited:
|
||||||
|
reasons.append(
|
||||||
|
"boot-inherited binding disagrees with the sanctioned in-session "
|
||||||
|
f"lease worktree '{lease_wt}'; the lease is live authority"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||||
|
"clear_eligible": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if lease_wt and lease_wt != active and not boot_inherited:
|
||||||
|
# Set after boot by tooling; disagreement is surfaced, never auto-cleared.
|
||||||
|
reasons.append(
|
||||||
|
"post-boot binding disagrees with the in-session lease worktree; "
|
||||||
|
"surfacing only (fail closed, no automatic clear)"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if role_env and role_env == active:
|
||||||
|
reasons.append("binding matches the role-specific worktree env binding")
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_CORROBORATED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
if boot_inherited and role in LEASE_BOUND_ROLES and not lease_wt:
|
||||||
|
reasons.append(
|
||||||
|
"binding was inherited at daemon boot, no sanctioned in-session "
|
||||||
|
f"lease corroborates it, and the {role} role binds workspaces "
|
||||||
|
"through the lease lifecycle; treat as unverified until a fresh "
|
||||||
|
"lease or managed reconnect re-establishes the workspace"
|
||||||
|
)
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
reasons.append("no contradicting evidence; binding treated as corroborated")
|
||||||
|
result.update({
|
||||||
|
"classification": CLASSIFICATION_CORROBORATED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def plan_recovery(classification_result: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Turn a classification into an explicit, fail-closed recovery plan."""
|
||||||
|
classification = classification_result.get("classification")
|
||||||
|
clear_eligible = bool(classification_result.get("clear_eligible"))
|
||||||
|
reasons = list(classification_result.get("reasons") or [])
|
||||||
|
|
||||||
|
if classification not in CLASSIFICATIONS:
|
||||||
|
return {
|
||||||
|
"action": RECOVERY_ACTION_NONE,
|
||||||
|
"clear_allowed": False,
|
||||||
|
"classification": classification,
|
||||||
|
"reasons": reasons + ["unknown classification (fail closed)"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if clear_eligible and classification in {
|
||||||
|
CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||||
|
}:
|
||||||
|
return {
|
||||||
|
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||||
|
"clear_allowed": True,
|
||||||
|
"classification": classification,
|
||||||
|
"active_worktree": classification_result.get("active_worktree"),
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
exact_next_action = None
|
||||||
|
if classification == CLASSIFICATION_UNVERIFIED_INHERITED:
|
||||||
|
exact_next_action = (
|
||||||
|
"managed recovery required: reconnect the MCP namespace through "
|
||||||
|
"the managed client configuration (or start a fresh session) so "
|
||||||
|
"the daemon boots without the inherited GITEA_ACTIVE_WORKTREE, or "
|
||||||
|
"corroborate the binding by acquiring a lease for that worktree; "
|
||||||
|
"do not clear or rewrite the environment manually"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"action": RECOVERY_ACTION_NONE,
|
||||||
|
"clear_allowed": False,
|
||||||
|
"classification": classification,
|
||||||
|
"active_worktree": classification_result.get("active_worktree"),
|
||||||
|
"reasons": reasons,
|
||||||
|
**({"exact_next_action": exact_next_action} if exact_next_action else {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_recovery(
|
||||||
|
plan: dict[str, Any],
|
||||||
|
env: dict[str, str] | os._Environ | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Apply a clear-eligible plan by removing the stale env binding.
|
||||||
|
|
||||||
|
This is the sanctioned in-daemon mechanism (#702 AC2); callers must
|
||||||
|
persist the returned record for audit. Refuses anything but an explicit
|
||||||
|
``clear_env`` plan.
|
||||||
|
"""
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
|
if not plan.get("clear_allowed") or plan.get("action") != RECOVERY_ACTION_CLEAR_ENV:
|
||||||
|
return {
|
||||||
|
"performed": False,
|
||||||
|
"action": RECOVERY_ACTION_NONE,
|
||||||
|
"reasons": ["recovery plan does not authorize clearing (fail closed)"],
|
||||||
|
}
|
||||||
|
cleared_value = (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None
|
||||||
|
env_map.pop(ACTIVE_WORKTREE_ENV, None)
|
||||||
|
return {
|
||||||
|
"performed": True,
|
||||||
|
"action": RECOVERY_ACTION_CLEAR_ENV,
|
||||||
|
"cleared_env": ACTIVE_WORKTREE_ENV,
|
||||||
|
"cleared_value": cleared_value,
|
||||||
|
"classification": plan.get("classification"),
|
||||||
|
"reasons": list(plan.get("reasons") or []),
|
||||||
|
}
|
||||||
+457
-13
@@ -1,4 +1,4 @@
|
|||||||
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620).
|
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).
|
||||||
|
|
||||||
#332 correctly hard-stops a reviewer session after a terminal live review
|
#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
|
mutation. After #559 those locks are durable on disk, so they can outlive the
|
||||||
@@ -12,6 +12,11 @@ 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
|
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.
|
(#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
|
This module is pure policy (no Gitea I/O). The MCP tool
|
||||||
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
||||||
helpers, and only then clears durable state when cleanup is allowed.
|
helpers, and only then clears durable state when cleanup is allowed.
|
||||||
@@ -80,17 +85,45 @@ def last_terminal_mutation(lock: dict | None) -> dict | None:
|
|||||||
return terminals[-1] if terminals else 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(
|
def prior_live_mutations_block_boundary(
|
||||||
lock: dict | None,
|
lock: dict | None,
|
||||||
*,
|
*,
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
expected_head_sha: str | None,
|
expected_head_sha: str | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True when prior live mutations block a new decision for PR+head (#620).
|
"""True when prior live mutations block a new decision for PR+head (#620/#693).
|
||||||
|
|
||||||
Historical terminals on a **different head of the same PR** do not block.
|
Historical terminals on a **different head of the same PR** do not block.
|
||||||
Any prior mutation on another PR, the same head, or without a comparable
|
Terminals on a **different PR** do not block (#693 cross-PR isolation).
|
||||||
head SHA fails closed (blocks).
|
Same PR + same head (or same PR without a comparable head SHA) blocks
|
||||||
|
unless a scoped correction applies.
|
||||||
|
|
||||||
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
||||||
that only stored ``ready_expected_head_sha`` still compare correctly
|
that only stored ``ready_expected_head_sha`` still compare correctly
|
||||||
@@ -98,7 +131,9 @@ def prior_live_mutations_block_boundary(
|
|||||||
"""
|
"""
|
||||||
if not lock:
|
if not lock:
|
||||||
return False
|
return False
|
||||||
if lock.get("correction_authorized"):
|
if correction_applies(
|
||||||
|
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
prior = list(lock.get("live_mutations") or [])
|
prior = list(lock.get("live_mutations") or [])
|
||||||
if not prior:
|
if not prior:
|
||||||
@@ -108,10 +143,14 @@ def prior_live_mutations_block_boundary(
|
|||||||
if not isinstance(m, dict):
|
if not isinstance(m, dict):
|
||||||
return True
|
return True
|
||||||
m_pr = m.get("pr_number")
|
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)
|
m_head = mutation_head_sha(m, lock)
|
||||||
if (
|
if (
|
||||||
m_pr == pr_number
|
target
|
||||||
and target
|
|
||||||
and m_head
|
and m_head
|
||||||
and not heads_equal(m_head, target)
|
and not heads_equal(m_head, target)
|
||||||
):
|
):
|
||||||
@@ -128,23 +167,34 @@ def terminal_boundary_allows_fresh_decision(
|
|||||||
expected_head_sha: str | None,
|
expected_head_sha: str | None,
|
||||||
operation: str,
|
operation: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620).
|
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).
|
||||||
|
|
||||||
Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when
|
For ``mark_ready`` / ``review`` / ``resume``:
|
||||||
the requested head differs from the last terminal's head. Merge is never
|
|
||||||
reopened by head change (approved head merge path is separate).
|
* **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).
|
||||||
"""
|
"""
|
||||||
if operation not in ("mark_ready", "review", "resume"):
|
if operation not in ("mark_ready", "review", "resume"):
|
||||||
return False
|
return False
|
||||||
if not lock:
|
if not lock:
|
||||||
return False
|
return False
|
||||||
if lock.get("correction_authorized"):
|
if correction_applies(
|
||||||
|
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
last = last_terminal_mutation(lock)
|
last = last_terminal_mutation(lock)
|
||||||
if last is None:
|
if last is None:
|
||||||
return True
|
return True
|
||||||
if last.get("pr_number") != pr_number:
|
last_pr = last.get("pr_number")
|
||||||
|
if last_pr is None:
|
||||||
return False
|
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)
|
locked_head = mutation_head_sha(last, lock)
|
||||||
target = normalize_head_sha(expected_head_sha)
|
target = normalize_head_sha(expected_head_sha)
|
||||||
if not locked_head or not target:
|
if not locked_head or not target:
|
||||||
@@ -440,6 +490,400 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
|||||||
return "\n".join(lines)
|
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
|
# #709 cross-profile cleanup, overwrite protection, recovery provenance
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.close",
|
"permission": "gitea.pr.close",
|
||||||
"role": "author",
|
"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": {
|
"address_pr_change_requests": {
|
||||||
"permission": "gitea.branch.push",
|
"permission": "gitea.branch.push",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -86,10 +95,22 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
"submit_pr_review": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"merge_pr": {
|
"merge_pr": {
|
||||||
"permission": "gitea.pr.merge",
|
"permission": "gitea.pr.merge",
|
||||||
"role": "merger",
|
"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.
|
# #695 AC8: controller quarantine of contaminated formal reviews.
|
||||||
# Apply path posts an append-only forensic audit comment (pr.comment).
|
# Apply path posts an append-only forensic audit comment (pr.comment).
|
||||||
"quarantine_contaminated_review": {
|
"quarantine_contaminated_review": {
|
||||||
@@ -104,6 +125,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "merger",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
|
"gitea_adopt_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"acquire_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_acquire_merger_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||||
"cleanup_obsolete_reviewer_comment_lease": {
|
"cleanup_obsolete_reviewer_comment_lease": {
|
||||||
@@ -145,6 +178,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"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).
|
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
|
||||||
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
||||||
"issue_irrecoverable_provenance_authorization": {
|
"issue_irrecoverable_provenance_authorization": {
|
||||||
|
|||||||
+63
-17
@@ -26,6 +26,14 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
so durable load/save never touches host state even after env clears.
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
|
import session_context_binding as session_ctx
|
||||||
|
|
||||||
|
# Each pytest item is an independent logical MCP session. Reset both before
|
||||||
|
# and after the item; the post-yield reset is in a finally block so an
|
||||||
|
# assertion, exception, or unittest teardown failure cannot pollute the
|
||||||
|
# next item. Production code has no automatic per-call reset path.
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
for env_key in [
|
for env_key in [
|
||||||
"GITEA_SESSION_PROFILE_LOCK",
|
"GITEA_SESSION_PROFILE_LOCK",
|
||||||
"GITEA_ACTIVE_WORKTREE",
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
@@ -80,11 +88,31 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
try:
|
try:
|
||||||
import mcp_server
|
import mcp_server
|
||||||
except Exception:
|
except Exception:
|
||||||
_state_tmp.cleanup()
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
_state_tmp.cleanup()
|
||||||
return
|
return
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
monkeypatch.setattr(gitea_config, "_active_profile_override", None)
|
||||||
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
|
||||||
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
|
||||||
|
# #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py
|
||||||
|
# into its own globals, so `import gitea_mcp_server` is a separate module
|
||||||
|
# object with its own identity caches; leaving it dirty leaks login across
|
||||||
|
# tests that import the implementation module directly.
|
||||||
|
try:
|
||||||
|
import gitea_mcp_server as _gitea_impl
|
||||||
|
|
||||||
|
monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {})
|
||||||
|
if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"):
|
||||||
|
monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"):
|
||||||
|
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
|
||||||
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
|
||||||
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
|
||||||
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
|
||||||
@@ -113,19 +141,37 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
capability_stop_terminal.clear()
|
capability_stop_terminal.clear()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
import capability_stop_terminal
|
||||||
|
capability_stop_terminal.clear()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import review_workflow_load
|
||||||
|
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
_state_tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# #714: deterministic workspace remotes only (no host git dependency).
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _deterministic_workspace_remotes():
|
||||||
|
try:
|
||||||
|
from mutation_profile_fixture import install_deterministic_remote_urls
|
||||||
|
install_deterministic_remote_urls()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
yield
|
yield
|
||||||
try:
|
|
||||||
import capability_stop_terminal
|
|
||||||
capability_stop_terminal.clear()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
import review_workflow_load
|
|
||||||
review_workflow_load._REVIEW_WORKFLOW_LOAD = None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
mcp_server._REVIEW_DECISION_LOCK = None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_state_tmp.cleanup()
|
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"""Centralized config-backed mutation fixture for #714.
|
||||||
|
|
||||||
|
Mutation tests must opt into this helper explicitly. It never grants
|
||||||
|
mutation authority via environment variables alone.
|
||||||
|
|
||||||
|
Design rules:
|
||||||
|
- temporary v2 configuration with non-empty ``allowed_repositories``
|
||||||
|
- profile-scoped operations (not every mutation op for every test)
|
||||||
|
- deterministic workspace remotes (no host Git dependency)
|
||||||
|
- one canonical repository per profile by default
|
||||||
|
- isolated state + cleanup in ``finally``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
PRGS_SLUG = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
PRGS_URL = f"https://gitea.prgs.cc/{PRGS_SLUG}.git"
|
||||||
|
MDCPS_SLUG = "913443/eAgenda"
|
||||||
|
MDCPS_URL = f"https://gitea.dadeschools.net/{MDCPS_SLUG}.git"
|
||||||
|
EXAMPLE_SLUG = "Example-Org/Example-Repo"
|
||||||
|
EXAMPLE_URL = f"https://gitea.example.com/{EXAMPLE_SLUG}.git"
|
||||||
|
TIMESHEET_SLUG = "Scaled-Tech-Consulting/Timesheet"
|
||||||
|
|
||||||
|
|
||||||
|
def _author_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _author_forbidden() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.review",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewer_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _merger_ops() -> list[str]:
|
||||||
|
return [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
context: str,
|
||||||
|
role: str,
|
||||||
|
base_url: str,
|
||||||
|
auth_env: str,
|
||||||
|
allowed_operations: Sequence[str],
|
||||||
|
forbidden_operations: Sequence[str],
|
||||||
|
allowed_repositories: Sequence[str],
|
||||||
|
username: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
body = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": context,
|
||||||
|
"role": role,
|
||||||
|
"base_url": base_url,
|
||||||
|
"auth": {"type": "env", "name": auth_env},
|
||||||
|
"allowed_operations": list(allowed_operations),
|
||||||
|
"forbidden_operations": list(forbidden_operations),
|
||||||
|
"allowed_repositories": list(allowed_repositories),
|
||||||
|
"execution_profile": name,
|
||||||
|
}
|
||||||
|
if username:
|
||||||
|
body["username"] = username
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def build_dual_remote_config(
|
||||||
|
*,
|
||||||
|
allowed_operations: Iterable[str] | None = None,
|
||||||
|
allowed_repositories_prgs: Sequence[str] | None = None,
|
||||||
|
allowed_repositories_mdcps: Sequence[str] | None = None,
|
||||||
|
extra_profiles: dict | None = None,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a minimal dual-host v2 config.
|
||||||
|
|
||||||
|
Each profile authorizes only its host-canonical repository by default.
|
||||||
|
``include_example_repo`` adds Example-Org/Example-Repo when a test patches
|
||||||
|
REMOTES to that synthetic unit-test identity.
|
||||||
|
"""
|
||||||
|
ops = list(allowed_operations or _author_ops())
|
||||||
|
forb = _author_forbidden()
|
||||||
|
prgs_repos = list(allowed_repositories_prgs or [PRGS_SLUG])
|
||||||
|
mdcps_repos = list(allowed_repositories_mdcps or [MDCPS_SLUG])
|
||||||
|
if include_example_repo:
|
||||||
|
for repos in (prgs_repos, mdcps_repos):
|
||||||
|
if EXAMPLE_SLUG not in repos:
|
||||||
|
repos.append(EXAMPLE_SLUG)
|
||||||
|
|
||||||
|
profiles = {
|
||||||
|
"test-author-prgs": _profile(
|
||||||
|
name="test-author-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="author",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=ops,
|
||||||
|
forbidden_operations=forb,
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
"test-author-dadeschools": _profile(
|
||||||
|
name="test-author-dadeschools",
|
||||||
|
context="mdcps",
|
||||||
|
role="author",
|
||||||
|
base_url="https://gitea.dadeschools.net",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=ops,
|
||||||
|
forbidden_operations=forb,
|
||||||
|
allowed_repositories=mdcps_repos,
|
||||||
|
),
|
||||||
|
"test-reviewer-prgs": _profile(
|
||||||
|
name="test-reviewer-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="reviewer",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=_reviewer_ops(),
|
||||||
|
forbidden_operations=[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.issue.create",
|
||||||
|
],
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
"test-merger-prgs": _profile(
|
||||||
|
name="test-merger-prgs",
|
||||||
|
context="prgs",
|
||||||
|
role="merger",
|
||||||
|
base_url="https://gitea.prgs.cc",
|
||||||
|
auth_env="GITEA_TOKEN_TEST",
|
||||||
|
allowed_operations=_merger_ops(),
|
||||||
|
forbidden_operations=[
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.issue.create",
|
||||||
|
],
|
||||||
|
allowed_repositories=prgs_repos,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if extra_profiles:
|
||||||
|
profiles.update(deepcopy(extra_profiles))
|
||||||
|
|
||||||
|
# Legacy aliases used by older tests — same host scope as the base profile.
|
||||||
|
for alias, base in (
|
||||||
|
("gitea-author", "test-author-prgs"),
|
||||||
|
("author-test", "test-author-dadeschools"),
|
||||||
|
("author", "test-author-dadeschools"),
|
||||||
|
("full-author", "test-author-dadeschools"),
|
||||||
|
("test-author", "test-author-dadeschools"),
|
||||||
|
("prgs-author", "test-author-prgs"),
|
||||||
|
("gitea-reviewer", "test-reviewer-prgs"),
|
||||||
|
("prgs-reviewer", "test-reviewer-prgs"),
|
||||||
|
("gitea-merger", "test-merger-prgs"),
|
||||||
|
("prgs-merger", "test-merger-prgs"),
|
||||||
|
):
|
||||||
|
if alias not in profiles and base in profiles:
|
||||||
|
clone = deepcopy(profiles[base])
|
||||||
|
clone["execution_profile"] = alias
|
||||||
|
profiles[alias] = clone
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"rules": {"allow_runtime_switching": True},
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": profiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_v2_config(**kwargs):
|
||||||
|
"""Back-compat alias."""
|
||||||
|
return build_dual_remote_config(
|
||||||
|
allowed_operations=kwargs.get("allowed_operations"),
|
||||||
|
extra_profiles=kwargs.get("extra_profiles"),
|
||||||
|
include_example_repo=bool(kwargs.get("include_example_repo")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_allowed_repositories(
|
||||||
|
config: dict,
|
||||||
|
repos: Sequence[str],
|
||||||
|
*,
|
||||||
|
profiles: Sequence[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return a deep copy of *config* with allowlists set on selected profiles."""
|
||||||
|
out = deepcopy(config)
|
||||||
|
targets = set(profiles) if profiles is not None else set(out.get("profiles") or {})
|
||||||
|
for name, prof in (out.get("profiles") or {}).items():
|
||||||
|
if name in targets:
|
||||||
|
prof["allowed_repositories"] = list(repos)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_all_profiles_have_scope(
|
||||||
|
config: dict,
|
||||||
|
default_repos: Sequence[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Add non-empty allowed_repositories to every profile that lacks one."""
|
||||||
|
out = deepcopy(config)
|
||||||
|
for _name, prof in (out.get("profiles") or {}).items():
|
||||||
|
raw = prof.get("allowed_repositories")
|
||||||
|
if not isinstance(raw, (list, tuple)) or not raw:
|
||||||
|
prof["allowed_repositories"] = list(default_repos)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def mutation_profile_env(
|
||||||
|
*,
|
||||||
|
profile_name: str = "test-author-dadeschools",
|
||||||
|
allowed_operations: Iterable[str] | None = None,
|
||||||
|
allowed_repositories: Sequence[str] | None = None,
|
||||||
|
workspace_urls: dict | None = None,
|
||||||
|
clear_env: bool = False,
|
||||||
|
extra_env: dict | None = None,
|
||||||
|
extra_profiles: dict | None = None,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
config: dict | None = None,
|
||||||
|
):
|
||||||
|
"""Context manager: config-backed mutation authority + deterministic remotes."""
|
||||||
|
import gitea_config
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
import session_context_binding as session_ctx
|
||||||
|
|
||||||
|
if config is None:
|
||||||
|
prgs_repos = None
|
||||||
|
mdcps_repos = None
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
# Caller-specified single allowlist applied to both host profiles.
|
||||||
|
prgs_repos = list(allowed_repositories)
|
||||||
|
mdcps_repos = list(allowed_repositories)
|
||||||
|
cfg = build_dual_remote_config(
|
||||||
|
allowed_operations=allowed_operations,
|
||||||
|
allowed_repositories_prgs=prgs_repos,
|
||||||
|
allowed_repositories_mdcps=mdcps_repos,
|
||||||
|
extra_profiles=extra_profiles,
|
||||||
|
include_example_repo=include_example_repo,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cfg = deepcopy(config)
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
cfg = ensure_all_profiles_have_scope(cfg, list(allowed_repositories))
|
||||||
|
|
||||||
|
urls = (
|
||||||
|
{"prgs": PRGS_URL, "dadeschools": MDCPS_URL}
|
||||||
|
if workspace_urls is None
|
||||||
|
else dict(workspace_urls)
|
||||||
|
)
|
||||||
|
|
||||||
|
tmp = tempfile.TemporaryDirectory(prefix="gitea-mut-cfg-")
|
||||||
|
try:
|
||||||
|
cfg_path = os.path.join(tmp.name, "profiles.json")
|
||||||
|
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(cfg, fh)
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": cfg_path,
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_TOKEN_TEST": "test-token",
|
||||||
|
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||||
|
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if extra_env:
|
||||||
|
env.update(extra_env)
|
||||||
|
|
||||||
|
def _remote_url(name: str):
|
||||||
|
if name in urls:
|
||||||
|
return urls[name]
|
||||||
|
# Prefer live REMOTES (tests often patch Example-Org).
|
||||||
|
try:
|
||||||
|
entry = srv.REMOTES.get(name) or {}
|
||||||
|
host = entry.get("host")
|
||||||
|
org = entry.get("org")
|
||||||
|
repo = entry.get("repo")
|
||||||
|
if host and org and repo:
|
||||||
|
if (
|
||||||
|
name == "prgs"
|
||||||
|
and org == "Scaled-Tech-Consulting"
|
||||||
|
and repo == "Timesheet"
|
||||||
|
):
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools" and repo == "Timesheet":
|
||||||
|
return MDCPS_URL
|
||||||
|
return f"https://{host}/{org}/{repo}.git"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
try:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"env": env,
|
||||||
|
"config_path": cfg_path,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"urls": urls,
|
||||||
|
"config": cfg,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=clear_env):
|
||||||
|
os.environ.setdefault(
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
env.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||||
|
)
|
||||||
|
with patch.object(srv, "_local_git_remote_url", side_effect=_remote_url):
|
||||||
|
mcp_srv = None
|
||||||
|
try:
|
||||||
|
import mcp_server as mcp_srv # type: ignore
|
||||||
|
except Exception:
|
||||||
|
mcp_srv = None
|
||||||
|
if mcp_srv is not None:
|
||||||
|
with patch.object(
|
||||||
|
mcp_srv, "_local_git_remote_url", side_effect=_remote_url
|
||||||
|
):
|
||||||
|
yield payload
|
||||||
|
else:
|
||||||
|
yield payload
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# Process-lifetime shared config for mass-migrating env-only mutation tests.
|
||||||
|
_SHARED_CFG_PATH = None
|
||||||
|
_SHARED_CFG_DIR = None
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH = None
|
||||||
|
|
||||||
|
|
||||||
|
def shared_mutation_config_path(*, include_example_repo: bool = False) -> str:
|
||||||
|
"""Write (once) a dual-remote mutation config and return its path."""
|
||||||
|
global _SHARED_CFG_PATH, _SHARED_CFG_DIR, _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
import atexit
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
if include_example_repo:
|
||||||
|
if _SHARED_CFG_WITH_EXAMPLE_PATH and os.path.isfile(
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
):
|
||||||
|
return _SHARED_CFG_WITH_EXAMPLE_PATH
|
||||||
|
if _SHARED_CFG_DIR is None:
|
||||||
|
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||||
|
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||||
|
path = os.path.join(_SHARED_CFG_DIR, "profiles-with-example.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(build_dual_remote_config(include_example_repo=True), fh)
|
||||||
|
_SHARED_CFG_WITH_EXAMPLE_PATH = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
if _SHARED_CFG_PATH and os.path.isfile(_SHARED_CFG_PATH):
|
||||||
|
return _SHARED_CFG_PATH
|
||||||
|
if _SHARED_CFG_DIR is None:
|
||||||
|
_SHARED_CFG_DIR = tempfile.mkdtemp(prefix="gitea-shared-mut-cfg-")
|
||||||
|
atexit.register(lambda: shutil.rmtree(_SHARED_CFG_DIR, ignore_errors=True))
|
||||||
|
_SHARED_CFG_PATH = os.path.join(_SHARED_CFG_DIR, "profiles.json")
|
||||||
|
with open(_SHARED_CFG_PATH, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(build_dual_remote_config(), fh)
|
||||||
|
return _SHARED_CFG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def shared_mutation_env(
|
||||||
|
profile_name: str = "test-author-dadeschools",
|
||||||
|
*,
|
||||||
|
include_example_repo: bool = False,
|
||||||
|
**extra,
|
||||||
|
) -> dict:
|
||||||
|
"""Env dict for mutation tests: config-backed + optional extras.
|
||||||
|
|
||||||
|
Always carries ``PYTEST_CURRENT_TEST`` so ``patch.dict(..., clear=True)``
|
||||||
|
does not strip the pytest marker and trip the #695 native-transport wall.
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": shared_mutation_config_path(
|
||||||
|
include_example_repo=include_example_repo
|
||||||
|
),
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_TOKEN_TEST": "test-token",
|
||||||
|
"PYTEST_CURRENT_TEST": os.environ.get(
|
||||||
|
"PYTEST_CURRENT_TEST", "mutation_profile_env"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
# Callers must not be able to drop the pytest marker by accident.
|
||||||
|
env.setdefault(
|
||||||
|
"PYTEST_CURRENT_TEST",
|
||||||
|
os.environ.get("PYTEST_CURRENT_TEST", "mutation_profile_env"),
|
||||||
|
)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def install_deterministic_remote_urls() -> None:
|
||||||
|
"""Patch server modules so workspace remotes are deterministic.
|
||||||
|
|
||||||
|
Prefer live ``REMOTES`` entries (so tests that patch Example-Org keep
|
||||||
|
workspace alignment). Map the historical prgs→Timesheet default to
|
||||||
|
Gitea-Tools so session binding matches the control repository under test.
|
||||||
|
"""
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
|
def _url(name: str):
|
||||||
|
try:
|
||||||
|
entry = srv.REMOTES.get(name) or {}
|
||||||
|
host = entry.get("host")
|
||||||
|
org = entry.get("org")
|
||||||
|
repo = entry.get("repo")
|
||||||
|
if host and org and repo:
|
||||||
|
if (
|
||||||
|
name == "prgs"
|
||||||
|
and org == "Scaled-Tech-Consulting"
|
||||||
|
and repo == "Timesheet"
|
||||||
|
):
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools" and repo == "Timesheet":
|
||||||
|
# Prefer mdcps eAgenda canonical for dual-config tests.
|
||||||
|
return MDCPS_URL
|
||||||
|
return f"https://{host}/{org}/{repo}.git"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if name == "prgs":
|
||||||
|
return PRGS_URL
|
||||||
|
if name == "dadeschools":
|
||||||
|
return MDCPS_URL
|
||||||
|
return None
|
||||||
|
|
||||||
|
srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||||
|
try:
|
||||||
|
import mcp_server as mcp_srv
|
||||||
|
|
||||||
|
mcp_srv._local_git_remote_url = _url # type: ignore[method-assign]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -65,11 +69,9 @@ class TestPreflightWarnings(unittest.TestCase):
|
|||||||
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
|
||||||
# gitea.issue.comment (see task_capability_map), so the gate must be
|
# gitea.issue.comment (see task_capability_map), so the gate must be
|
||||||
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"test-author-prgs",
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
"""Regression tests for gitea_assess_conflict_fix_push structured failures (#519)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
+46
-16
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
"""Tests for Gitea MCP mutating-action audit logging (issue #18).
|
||||||
|
|
||||||
Covers the pure audit module (redaction, event building, sink writes) and the
|
Covers the pure audit module (redaction, event building, sink writes) and the
|
||||||
@@ -161,11 +165,23 @@ class _AuditWiringBase(unittest.TestCase):
|
|||||||
self._dir.cleanup()
|
self._dir.cleanup()
|
||||||
|
|
||||||
def _env(self, **extra):
|
def _env(self, **extra):
|
||||||
env = {"GITEA_AUDIT_LOG": self.audit_path,
|
# Default: prgs-aligned author with create/close (remote=prgs tests).
|
||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
# Callers override GITEA_MCP_PROFILE for merger/reviewer paths.
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
profile = extra.pop("GITEA_MCP_PROFILE", None) or extra.pop(
|
||||||
"read,merge,gitea.issue.create,gitea.issue.close")}
|
"GITEA_PROFILE_NAME", None
|
||||||
|
) or "test-author-prgs"
|
||||||
|
env = shared_mutation_env(
|
||||||
|
profile,
|
||||||
|
GITEA_AUDIT_LOG=self.audit_path,
|
||||||
|
GITEA_TOKEN_TEST="test-token",
|
||||||
|
)
|
||||||
|
# Strip legacy env-only authority knobs if callers still pass them;
|
||||||
|
# config-backed profiles own operations and repositories (#714).
|
||||||
|
extra.pop("GITEA_ALLOWED_OPERATIONS", None)
|
||||||
|
extra.pop("GITEA_FORBIDDEN_OPERATIONS", None)
|
||||||
|
extra.pop("GITEA_PROFILE_NAME", None)
|
||||||
env.update(extra)
|
env.update(extra)
|
||||||
|
env["GITEA_MCP_PROFILE"] = profile
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def _records(self):
|
def _records(self):
|
||||||
@@ -196,7 +212,7 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
rec = recs[0]
|
rec = recs[0]
|
||||||
self.assertEqual(rec["action"], "create_issue")
|
self.assertEqual(rec["action"], "create_issue")
|
||||||
self.assertEqual(rec["result"], "succeeded")
|
self.assertEqual(rec["result"], "succeeded")
|
||||||
self.assertEqual(rec["profile_name"], "gitea-author")
|
self.assertIn(rec["profile_name"], ("gitea-author", "test-author-prgs", "prgs-author"))
|
||||||
self.assertEqual(rec["authenticated_username"], "author-bot")
|
self.assertEqual(rec["authenticated_username"], "author-bot")
|
||||||
self.assertEqual(rec["issue_number"], 11)
|
self.assertEqual(rec["issue_number"], 11)
|
||||||
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
self.assertEqual(rec["request_metadata"]["title"], "Add thing")
|
||||||
@@ -239,10 +255,11 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
|||||||
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
def test_disabled_writes_nothing_and_no_extra_call(self, _auth, _get_all, mock_api, _role):
|
||||||
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
|
# No GITEA_AUDIT_LOG -> audit is a no-op: one create POST, no file.
|
||||||
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
mock_api.return_value = {"number": 1, "html_url": "http://x/1"}
|
||||||
with patch.dict(os.environ, {
|
with patch.dict(
|
||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
os.environ,
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
shared_mutation_env("test-author-prgs"),
|
||||||
}, clear=True):
|
clear=True,
|
||||||
|
):
|
||||||
gitea_create_issue(title="x", remote="prgs")
|
gitea_create_issue(title="x", remote="prgs")
|
||||||
issue_posts = [
|
issue_posts = [
|
||||||
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
c for c in mock_api.call_args_list if c.args[0] == "POST"
|
||||||
@@ -342,8 +359,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
self._pr("author-bot"), approval, # gate 7 feedback
|
self._pr("author-bot"), approval, # gate 7 feedback
|
||||||
{}, {"merged_commit_sha": "c1"},
|
{}, {"merged_commit_sha": "c1"},
|
||||||
]
|
]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
|
||||||
@@ -362,8 +378,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
def test_merge_blocked_audited(self, _auth, mock_api):
|
def test_merge_blocked_audited(self, _auth, mock_api):
|
||||||
# Self-author merge is blocked; must still be recorded as blocked.
|
# Self-author merge is blocked; must still be recorded as blocked.
|
||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
env = self._env(GITEA_MCP_PROFILE="test-merger-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,merge")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
|
||||||
@@ -385,11 +400,23 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||||
]
|
]
|
||||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
env = self._env(GITEA_MCP_PROFILE="test-reviewer-prgs")
|
||||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
import session_context_binding as _sc
|
||||||
|
from tests.test_mcp_server import (
|
||||||
|
_init_reviewer_session,
|
||||||
|
_install_owned_reviewer_lease,
|
||||||
|
)
|
||||||
from mcp_server import gitea_mark_final_review_decision
|
from mcp_server import gitea_mark_final_review_decision
|
||||||
|
|
||||||
|
# Rebind after profile env is applied so durable session state
|
||||||
|
# matches the active reviewer profile (#714 / #695).
|
||||||
|
_sc._reset_session_context_for_testing()
|
||||||
|
_init_reviewer_session("prgs")
|
||||||
|
lease = _install_owned_reviewer_lease(8)
|
||||||
|
lease.start()
|
||||||
|
self.addCleanup(lease.stop)
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
gitea_mark_final_review_decision(
|
gitea_mark_final_review_decision(
|
||||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||||
)
|
)
|
||||||
@@ -398,7 +425,10 @@ class TestGatedToolAudit(_AuditWiringBase):
|
|||||||
body="LGTM", remote="prgs",
|
body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
)
|
)
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(
|
||||||
|
r["performed"],
|
||||||
|
msg=f"submit_pr_review blocked: {r}",
|
||||||
|
)
|
||||||
recs = self._records()
|
recs = self._records()
|
||||||
self.assertEqual(len(recs), 1)
|
self.assertEqual(len(recs), 1)
|
||||||
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
self.assertEqual(recs[0]["action"], "submit_pr_review")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ CONFIG = {
|
|||||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||||
"forbidden_operations": ["gitea.pr.create"],
|
"forbidden_operations": ["gitea.pr.create"],
|
||||||
"execution_profile": "commit-author",
|
"execution_profile": "commit-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"pr-only-author": {
|
"pr-only-author": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -39,6 +40,7 @@ CONFIG = {
|
|||||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||||
"forbidden_operations": ["gitea.repo.commit"],
|
"forbidden_operations": ["gitea.repo.commit"],
|
||||||
"execution_profile": "pr-only-author",
|
"execution_profile": "pr-only-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"reviewer-profile": {
|
"reviewer-profile": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -51,6 +53,7 @@ CONFIG = {
|
|||||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
|
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push",
|
||||||
],
|
],
|
||||||
"execution_profile": "reviewer-profile",
|
"execution_profile": "reviewer-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ CONFIG = {
|
|||||||
],
|
],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"reviewer-no-commit": {
|
"reviewer-no-commit": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -49,6 +50,7 @@ CONFIG = {
|
|||||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
||||||
],
|
],
|
||||||
"execution_profile": "reviewer-no-commit",
|
"execution_profile": "reviewer-no-commit",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ CONFIG = {
|
|||||||
"gitea.pr.review",
|
"gitea.pr.review",
|
||||||
],
|
],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -227,6 +228,7 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
def test_commit_files_traversal_blocked(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"login": "author-user"}
|
||||||
# Remove active lock file to ensure it fails on traversal/invalid locks
|
# Remove active lock file to ensure it fails on traversal/invalid locks
|
||||||
os.remove(self.lock_file_path)
|
os.remove(self.lock_file_path)
|
||||||
|
|
||||||
@@ -248,6 +250,7 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"login": "author-user"}
|
||||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
mcp_server.gitea_commit_files(
|
mcp_server.gitea_commit_files(
|
||||||
@@ -266,6 +269,7 @@ class TestCommitPayloads(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"login": "author-user"}
|
||||||
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
with patch.dict(os.environ, self._env("full-author"), clear=True):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
mcp_server.gitea_commit_files(
|
mcp_server.gitea_commit_files(
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
"""Tests for canonical JSON runtime-profile configuration (gitea_config) and
|
||||||
its integration into gitea_auth.get_profile / get_auth_header.
|
its integration into gitea_auth.get_profile / get_auth_header.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
"""Tests for create_issue.py.
|
"""Tests for create_issue.py.
|
||||||
|
|
||||||
Every test mocks auth functions so no real network calls or keychain
|
Every test mocks auth functions so no real network calls or keychain
|
||||||
@@ -12,7 +13,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import contextlib
|
import contextlib
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch, MagicMock, patch
|
||||||
|
|
||||||
# The module under test lives in the repo root, not a package.
|
# The module under test lives in the repo root, not a package.
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
@@ -92,6 +93,26 @@ class TestRemoteResolution(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@unittest.skipIf(_SKIP, _REASON)
|
@unittest.skipIf(_SKIP, _REASON)
|
||||||
class TestAPIPayload(unittest.TestCase):
|
class TestAPIPayload(unittest.TestCase):
|
||||||
|
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||||
|
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||||
|
migration of legacy omitted-target assertions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._ws_env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._ws_env.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._ws_env.stop()
|
||||||
|
|
||||||
"""Ensure the JSON payload sent to Gitea is correct."""
|
"""Ensure the JSON payload sent to Gitea is correct."""
|
||||||
|
|
||||||
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
|
||||||
@@ -142,7 +163,7 @@ class TestAPIPayload(unittest.TestCase):
|
|||||||
url = mock_api.call_args[0][1]
|
url = mock_api.call_args[0][1]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
url,
|
url,
|
||||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/issues",
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/issues",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
"""Tests for create_pr.py.
|
"""Tests for create_pr.py.
|
||||||
|
|
||||||
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
Every test mocks `get_credentials` and `urllib.request.urlopen` so no real
|
||||||
@@ -8,7 +9,7 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import contextlib
|
import contextlib
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch, MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
import create_pr # noqa: E402
|
import create_pr # noqa: E402
|
||||||
@@ -74,6 +75,26 @@ class TestRemoteResolution(unittest.TestCase):
|
|||||||
# API payload
|
# API payload
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestAPIPayload(unittest.TestCase):
|
class TestAPIPayload(unittest.TestCase):
|
||||||
|
"""#714: omitted --org/--repo uses workspace-bound repo (Gitea-Tools),
|
||||||
|
not the historical REMOTES Timesheet default. Intentional security-policy
|
||||||
|
migration of legacy omitted-target assertions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._ws_env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_TEST_WORKSPACE_REMOTE_URL": (
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._ws_env.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._ws_env.stop()
|
||||||
|
|
||||||
|
|
||||||
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
|
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
|
||||||
def test_payload_fields(self, _cred):
|
def test_payload_fields(self, _cred):
|
||||||
@@ -113,7 +134,7 @@ class TestAPIPayload(unittest.TestCase):
|
|||||||
url = MockReq.call_args[0][0]
|
url = MockReq.call_args[0][0]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
url,
|
url,
|
||||||
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Timesheet/pulls",
|
"https://gitea.prgs.cc/api/v1/repos/Scaled-Tech-Consulting/Gitea-Tools/pulls",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ HEAD_C = "c" * 40
|
|||||||
|
|
||||||
|
|
||||||
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
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 {
|
return {
|
||||||
"task": "review_pr",
|
"task": "review_pr",
|
||||||
"remote": "prgs",
|
"remote": "prgs",
|
||||||
@@ -40,9 +44,11 @@ def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, read
|
|||||||
"ready_remote": "prgs" if ready_pr else None,
|
"ready_remote": "prgs" if ready_pr else None,
|
||||||
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
"ready_org": "Scaled-Tech-Consulting" if ready_pr else None,
|
||||||
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
"ready_repo": "Gitea-Tools" if ready_pr else None,
|
||||||
"live_mutations": list(mutations or []),
|
"live_mutations": mutations,
|
||||||
"correction_authorized": correction,
|
"correction_authorized": correction,
|
||||||
"correction_reason": None,
|
"correction_reason": "test" if correction else None,
|
||||||
|
"correction_pr_number": corr_pr,
|
||||||
|
"correction_head_sha": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -174,12 +180,13 @@ class TestHardStopHeadScope(unittest.TestCase):
|
|||||||
self.assertTrue(reasons)
|
self.assertTrue(reasons)
|
||||||
self.assertIn("#332", reasons[0])
|
self.assertIn("#332", reasons[0])
|
||||||
|
|
||||||
def test_rc_head_a_blocks_other_pr(self):
|
def test_rc_head_a_allows_other_pr_via_cross_pr_isolation(self):
|
||||||
|
"""#693: foreign-PR terminal must not hard-stop mark_ready on another PR."""
|
||||||
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
_seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A)
|
||||||
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
700, "mark_ready", expected_head_sha=HEAD_B
|
700, "mark_ready", expected_head_sha=HEAD_B
|
||||||
)
|
)
|
||||||
self.assertTrue(reasons)
|
self.assertEqual(reasons, [])
|
||||||
|
|
||||||
def test_legacy_619_ledger_allows_new_head(self):
|
def test_legacy_619_ledger_allows_new_head(self):
|
||||||
"""PR #619-style durable lock without mutation head_sha."""
|
"""PR #619-style durable lock without mutation head_sha."""
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""#685: gitea_resolve_task_capability must be side-effect free.
|
||||||
|
|
||||||
|
Stale-runtime detection remains fail-closed, but the resolver must never:
|
||||||
|
* touch mcp_config.json (or any MCP client config)
|
||||||
|
* spawn recovery threads
|
||||||
|
* call os._exit / terminate the serving process
|
||||||
|
* claim that an auto-restart was triggered
|
||||||
|
|
||||||
|
Recovery is owned by the IDE/client reconnect path only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_PROFILES = (
|
||||||
|
("create_issue", "prgs-author", "author"),
|
||||||
|
("review_pr", "prgs-reviewer", "reviewer"),
|
||||||
|
("merge_pr", "prgs-merger", "merger"),
|
||||||
|
("reconciliation_cleanup", "prgs-reconciler", "reconciler"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_self_ps_mocks(profile: str = "prgs-author"):
|
||||||
|
"""Build subprocess mocks: self PID is stale vs code mtime."""
|
||||||
|
mock_getpid = MagicMock(return_value=12345)
|
||||||
|
mock_exists = MagicMock(return_value=True)
|
||||||
|
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||||
|
mock_getmtime = MagicMock(return_value=code_time.timestamp())
|
||||||
|
|
||||||
|
ps_output = (
|
||||||
|
" PID LSTART COMMAND\n"
|
||||||
|
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||||
|
)
|
||||||
|
mock_run_ps = MagicMock()
|
||||||
|
mock_run_ps.stdout = ps_output
|
||||||
|
|
||||||
|
mock_run_env = MagicMock()
|
||||||
|
mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}"
|
||||||
|
|
||||||
|
mock_run_git = MagicMock()
|
||||||
|
mock_run_git.stdout = "SAME"
|
||||||
|
|
||||||
|
def side_effect(args, **kwargs):
|
||||||
|
if args[0] == "ps" and "eww" in args:
|
||||||
|
return mock_run_env
|
||||||
|
if args[0] == "ps":
|
||||||
|
return mock_run_ps
|
||||||
|
if args[0] == "git":
|
||||||
|
return mock_run_git
|
||||||
|
raise ValueError(f"Unexpected subprocess args: {args}")
|
||||||
|
|
||||||
|
mock_run = MagicMock(side_effect=side_effect)
|
||||||
|
return mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("os.path.getmtime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch("os.getpid")
|
||||||
|
@patch("os.utime")
|
||||||
|
@patch("threading.Thread")
|
||||||
|
@patch("os._exit")
|
||||||
|
def test_stale_self_does_not_touch_config_or_exit(
|
||||||
|
self,
|
||||||
|
mock_exit,
|
||||||
|
mock_thread,
|
||||||
|
mock_utime,
|
||||||
|
mock_getpid,
|
||||||
|
mock_exists,
|
||||||
|
mock_getmtime,
|
||||||
|
mock_run,
|
||||||
|
):
|
||||||
|
mock_getpid.return_value = 12345
|
||||||
|
mock_exists.return_value = True
|
||||||
|
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||||
|
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||||
|
|
||||||
|
before_threads = threading.active_count()
|
||||||
|
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||||
|
"create_issue", ["prgs-author"]
|
||||||
|
)
|
||||||
|
after_threads = threading.active_count()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
any("stale-runtime" in r and "active Gitea MCP server process is stale" in r
|
||||||
|
for r in reasons),
|
||||||
|
reasons,
|
||||||
|
)
|
||||||
|
# Must not claim auto-restart / config touch
|
||||||
|
blob = " ".join(reasons)
|
||||||
|
self.assertNotIn("Auto-restart has been triggered", blob)
|
||||||
|
self.assertNotIn("touched mcp_config", blob)
|
||||||
|
self.assertNotIn("will cleanly exit", blob)
|
||||||
|
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
self.assertEqual(before_threads, after_threads)
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False)
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("os.path.getmtime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch("os.getpid")
|
||||||
|
@patch("os.utime")
|
||||||
|
def test_repeated_stale_calls_do_not_trigger_restart_loop(
|
||||||
|
self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||||
|
):
|
||||||
|
mock_getpid.return_value = 12345
|
||||||
|
mock_exists.return_value = True
|
||||||
|
mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp()
|
||||||
|
mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
reasons = mcp_server._check_mcp_runtimes_diagnostics(
|
||||||
|
"create_issue", ["prgs-author"]
|
||||||
|
)
|
||||||
|
self.assertTrue(any("stale-runtime" in r for r in reasons))
|
||||||
|
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
|
||||||
|
def test_trigger_mcp_auto_restart_removed(self):
|
||||||
|
"""#685 AC: auto-restart helper is removed (unreachable from read-only)."""
|
||||||
|
self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart"))
|
||||||
|
self.assertFalse(hasattr(mcp_server, "_restart_triggered"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685ResolverTypedBlocker(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||||
|
mcp_server.capability_stop_terminal.clear()
|
||||||
|
|
||||||
|
def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str):
|
||||||
|
allowed = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
profile = {
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"role": role,
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"profiles": {
|
||||||
|
profile_name: {
|
||||||
|
"role": role,
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||||
|
profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||||
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||||
|
mcp_server.gitea_config, "load_config", return_value=config
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="test-user"
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_ensure_matching_profile", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_preflight_check", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_mutation_authority", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "init_review_decision_lock", return_value=None
|
||||||
|
), patch(
|
||||||
|
"subprocess.run", mock_run
|
||||||
|
), patch(
|
||||||
|
"os.path.getmtime", mock_getmtime
|
||||||
|
), patch(
|
||||||
|
"os.path.exists", mock_exists
|
||||||
|
), patch(
|
||||||
|
"os.getpid", mock_getpid
|
||||||
|
), patch(
|
||||||
|
"os.utime"
|
||||||
|
) as mock_utime, patch(
|
||||||
|
"threading.Thread"
|
||||||
|
) as mock_thread, patch(
|
||||||
|
"os._exit"
|
||||||
|
) as mock_exit:
|
||||||
|
result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||||
|
return result, mock_utime, mock_thread, mock_exit
|
||||||
|
|
||||||
|
def test_stale_returns_typed_blocker_fields(self):
|
||||||
|
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||||
|
"create_issue", "prgs-author", "author"
|
||||||
|
)
|
||||||
|
self.assertTrue(result.get("restart_required"), result)
|
||||||
|
self.assertTrue(result.get("stop_required"), result)
|
||||||
|
self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required")
|
||||||
|
self.assertIs(result.get("mutation_performed"), False)
|
||||||
|
action = result.get("exact_safe_next_action") or ""
|
||||||
|
self.assertIn("reconnect", action.lower())
|
||||||
|
self.assertNotIn("None; ready for operations", action)
|
||||||
|
reason = result.get("reason") or ""
|
||||||
|
self.assertIn("stale-runtime", reason)
|
||||||
|
self.assertNotIn("Auto-restart has been triggered", reason)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
|
||||||
|
def test_all_four_role_profiles_get_same_side_effect_free_contract(self):
|
||||||
|
for task, profile, role in ROLE_PROFILES:
|
||||||
|
with self.subTest(task=task, profile=profile):
|
||||||
|
# Skip tasks that may be unknown on this branch
|
||||||
|
try:
|
||||||
|
import task_capability_map as tcm
|
||||||
|
|
||||||
|
tcm.required_permission(task)
|
||||||
|
except Exception:
|
||||||
|
self.skipTest(f"task {task} not in capability map")
|
||||||
|
|
||||||
|
result, mock_utime, mock_thread, mock_exit = (
|
||||||
|
self._resolve_with_stale_runtime(task, profile, role)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
result.get("restart_required") or result.get("stop_required"),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||||
|
)
|
||||||
|
self.assertIs(result.get("mutation_performed"), False, result)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
|
||||||
|
def test_config_mtime_and_contents_unchanged(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg = os.path.join(tmp, "mcp_config.json")
|
||||||
|
original = '{"servers": {"gitea-author": {}}}'
|
||||||
|
with open(cfg, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(original)
|
||||||
|
mtime_before = os.path.getmtime(cfg)
|
||||||
|
|
||||||
|
result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime(
|
||||||
|
"create_issue", "prgs-author", "author"
|
||||||
|
)
|
||||||
|
# Force-path also must not use real utime when diagnostics runs
|
||||||
|
with open(cfg, encoding="utf-8") as fh:
|
||||||
|
after = fh.read()
|
||||||
|
self.assertEqual(after, original)
|
||||||
|
self.assertEqual(os.path.getmtime(cfg), mtime_before)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
self.assertTrue(result.get("restart_required"), result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685MutationGatesStillFailClosed(unittest.TestCase):
|
||||||
|
def test_parity_stale_still_reports_restart_required(self):
|
||||||
|
"""Mutation-facing parity gate remains fail-closed when heads differ."""
|
||||||
|
import master_parity_gate as mpg
|
||||||
|
|
||||||
|
out = mpg.assess_master_parity(
|
||||||
|
{"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||||
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
)
|
||||||
|
self.assertFalse(out.get("in_parity"))
|
||||||
|
self.assertTrue(out.get("restart_required") or out.get("stale"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685DocstringReadOnlyContract(unittest.TestCase):
|
||||||
|
def test_resolve_docstring_declares_side_effect_free(self):
|
||||||
|
doc = mcp_server.gitea_resolve_task_capability.__doc__ or ""
|
||||||
|
lower = doc.lower()
|
||||||
|
self.assertTrue(
|
||||||
|
"side-effect" in lower or "read-only" in lower or "does not mutate" in lower,
|
||||||
|
doc,
|
||||||
|
)
|
||||||
|
self.assertNotIn("auto-restart", lower)
|
||||||
|
self.assertNotIn("os._exit", lower)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase):
|
||||||
|
"""After merging master: #685 reconnect blocker + #702 report-only binding."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||||
|
mcp_server.capability_stop_terminal.clear()
|
||||||
|
|
||||||
|
def test_resolve_uses_report_only_stale_binding_assessment(self):
|
||||||
|
"""Capability resolve must call stale-binding assess with auto_recover=False."""
|
||||||
|
allowed = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks(
|
||||||
|
"prgs-author"
|
||||||
|
)
|
||||||
|
fake_binding = {
|
||||||
|
"classification": "missing_path",
|
||||||
|
"active_worktree": "/tmp/gone",
|
||||||
|
"reasons": ["path missing"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_FORCE_MCP_RUNTIME_CHECK": "1",
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
), patch.object(mcp_server, "get_profile", return_value=profile), patch.object(
|
||||||
|
mcp_server.gitea_config, "load_config", return_value=config
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="test-user"
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_ensure_matching_profile", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_preflight_check", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_mutation_authority", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "init_review_decision_lock", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_assess_stale_active_binding",
|
||||||
|
return_value=fake_binding,
|
||||||
|
) as mock_assess, patch(
|
||||||
|
"subprocess.run", mock_run
|
||||||
|
), patch(
|
||||||
|
"os.path.getmtime", mock_getmtime
|
||||||
|
), patch(
|
||||||
|
"os.path.exists", mock_exists
|
||||||
|
), patch(
|
||||||
|
"os.getpid", mock_getpid
|
||||||
|
), patch(
|
||||||
|
"os.utime"
|
||||||
|
) as mock_utime, patch(
|
||||||
|
"threading.Thread"
|
||||||
|
) as mock_thread, patch(
|
||||||
|
"os._exit"
|
||||||
|
) as mock_exit:
|
||||||
|
result = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_assess.assert_called()
|
||||||
|
# Every call must be report-only (no env clear / session-state write).
|
||||||
|
for call in mock_assess.call_args_list:
|
||||||
|
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||||
|
self.assertEqual(
|
||||||
|
result.get("blocker_kind"), "runtime_reconnect_required", result
|
||||||
|
)
|
||||||
|
self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result)
|
||||||
|
self.assertIs(result.get("mutation_performed"), False, result)
|
||||||
|
mock_utime.assert_not_called()
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -561,12 +561,29 @@ class TestIssue691SupersededHeadCleanup(unittest.TestCase):
|
|||||||
self.assertTrue(any("owns the lease" in r for r in result["reasons"]))
|
self.assertTrue(any("owns the lease" in r for r in result["reasons"]))
|
||||||
|
|
||||||
def test_superseded_without_terminal_review_denied(self):
|
def test_superseded_without_terminal_review_denied(self):
|
||||||
|
# Pre-expiry the missing terminal review is ambiguous (fail closed);
|
||||||
|
# post-expiry with unknown owner evidence it is a crash-orphan (#702)
|
||||||
|
# that still fails closed until owner-process exit is proven.
|
||||||
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc)
|
||||||
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc)
|
||||||
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1))
|
||||||
result = _assess([claim], formal_reviews=[], now=now)
|
result = _assess(
|
||||||
|
[claim], formal_reviews=[], now=now, owner_process_alive=None
|
||||||
|
)
|
||||||
self.assertFalse(result["cleanup_allowed"])
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
|
self.assertEqual(
|
||||||
|
result["classification"], "orphaned_expired_superseded_head"
|
||||||
|
)
|
||||||
|
|
||||||
|
fresh_expires = now + timedelta(minutes=60)
|
||||||
|
fresh = _lease_comment(
|
||||||
|
expires_at=fresh_expires, last_activity=now - timedelta(minutes=5)
|
||||||
|
)
|
||||||
|
pre_expiry = _assess([fresh], formal_reviews=[], now=now)
|
||||||
|
self.assertFalse(pre_expiry["cleanup_allowed"])
|
||||||
|
self.assertEqual(
|
||||||
|
pre_expiry["classification"], "ambiguous_conflicting_evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestIssue691DiagnoseClassifications(unittest.TestCase):
|
class TestIssue691DiagnoseClassifications(unittest.TestCase):
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
"""Review-decision-lock recovery / cross-PR isolation (#693).
|
||||||
|
|
||||||
|
Reproduces the PR #688 → PR #692 formal-review sequence:
|
||||||
|
|
||||||
|
* durable lock holds REQUEST_CHANGES on open PR #688 @ head A (review_id 425)
|
||||||
|
* reviewer leases PR #692 @ head B (6d86db…)
|
||||||
|
* mark_final for #692 must succeed without correction unlock
|
||||||
|
* cleanup of open #688 terminal remains forbidden (#594)
|
||||||
|
* authorize_review_correction for #688 cannot unlock #692
|
||||||
|
* eventual #692 APPROVE is unique and head-bound (review_id 426)
|
||||||
|
* mark_final is idempotent for same PR/action/head
|
||||||
|
* diagnosis returns one exact next_action + durable handoff payload
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
import review_workflow_load
|
||||||
|
import stale_review_decision_lock as srdl
|
||||||
|
|
||||||
|
# PR #688 / #692 incident fixtures (sanitized reconstruction from issue #693)
|
||||||
|
HEAD_688 = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0"
|
||||||
|
HEAD_692 = "6d86db793bbdfaed16bd54d93171f1dd66f234ca"
|
||||||
|
PR_A = 688
|
||||||
|
PR_B = 692
|
||||||
|
REVIEW_ID_A = 425
|
||||||
|
REVIEW_ID_B = 426
|
||||||
|
|
||||||
|
RC_688 = {
|
||||||
|
"pr_number": PR_A,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": REVIEW_ID_A,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
"head_sha": HEAD_688,
|
||||||
|
}
|
||||||
|
APPROVE_692 = {
|
||||||
|
"pr_number": PR_B,
|
||||||
|
"action": "approve",
|
||||||
|
"review_id": REVIEW_ID_B,
|
||||||
|
"review_state": "approve",
|
||||||
|
"head_sha": HEAD_692,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(mutations=None, **kwargs):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
base = {
|
||||||
|
"task": "review_pr",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"session_pid": os.getpid(),
|
||||||
|
"session_profile": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"final_review_decision_ready": False,
|
||||||
|
"ready_pr_number": None,
|
||||||
|
"ready_action": None,
|
||||||
|
"ready_expected_head_sha": None,
|
||||||
|
"ready_remote": None,
|
||||||
|
"ready_org": None,
|
||||||
|
"ready_repo": None,
|
||||||
|
"live_mutations": list(mutations or []),
|
||||||
|
"correction_authorized": False,
|
||||||
|
"correction_reason": None,
|
||||||
|
"correction_pr_number": None,
|
||||||
|
"correction_head_sha": None,
|
||||||
|
"updated_at": now,
|
||||||
|
"recorded_at": now,
|
||||||
|
"writer_pid": os.getpid(),
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
# Ensure TTL fields stay fresh unless the caller is testing expiry.
|
||||||
|
if "recorded_at" not in kwargs:
|
||||||
|
base["recorded_at"] = now
|
||||||
|
if "updated_at" not in kwargs:
|
||||||
|
base["updated_at"] = now
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
REVIEWER_ENV = {
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": (
|
||||||
|
"gitea.read,gitea.pr.review,gitea.pr.approve,"
|
||||||
|
"gitea.pr.request_changes,gitea.pr.comment"
|
||||||
|
),
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
|
||||||
|
}
|
||||||
|
|
||||||
|
REVIEWER_PROFILE = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.merge"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(mutations=None, **kwargs):
|
||||||
|
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||||
|
mcp_server._save_review_decision_lock(_lock(mutations, **kwargs))
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
|
||||||
|
|
||||||
|
def _no_lease():
|
||||||
|
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pr(number, head):
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"state": "open",
|
||||||
|
"merged": False,
|
||||||
|
"merged_at": None,
|
||||||
|
"head": {"sha": head},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossPrIsolationPure(unittest.TestCase):
|
||||||
|
def test_foreign_terminal_does_not_block_mark_boundary(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=PR_A, expected_head_sha=HEAD_688
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_terminal_boundary_allows_fresh_decision_on_other_pr(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.terminal_boundary_allows_fresh_decision(
|
||||||
|
lock,
|
||||||
|
pr_number=PR_B,
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
operation="mark_ready",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.terminal_boundary_allows_fresh_decision(
|
||||||
|
lock,
|
||||||
|
pr_number=PR_A,
|
||||||
|
expected_head_sha=HEAD_688,
|
||||||
|
operation="mark_ready",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unscoped_correction_does_not_apply(self):
|
||||||
|
lock = _lock([RC_688], correction_authorized=True, correction_reason="x")
|
||||||
|
# Missing correction_pr_number → fail closed (#693)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scoped_correction_only_for_named_pr(self):
|
||||||
|
lock = _lock(
|
||||||
|
[RC_688],
|
||||||
|
correction_authorized=True,
|
||||||
|
correction_reason="mistake",
|
||||||
|
correction_pr_number=PR_A,
|
||||||
|
correction_head_sha=HEAD_688,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.correction_applies(lock, pr_number=PR_B, expected_head_sha=HEAD_692)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassification(unittest.TestCase):
|
||||||
|
def test_foreign_pr_terminal_classification(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
c = srdl.classify_review_decision_lock(
|
||||||
|
lock,
|
||||||
|
target_pr_number=PR_B,
|
||||||
|
target_head_sha=HEAD_692,
|
||||||
|
pr_live=_open_pr(PR_A, HEAD_688),
|
||||||
|
active_profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(c["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||||
|
self.assertTrue(c["mark_final_allowed"])
|
||||||
|
self.assertFalse(c["correction_eligible"])
|
||||||
|
self.assertIn("do not call gitea_authorize_review_correction", c["next_action"])
|
||||||
|
|
||||||
|
def test_same_head_terminal_classification(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
c = srdl.classify_review_decision_lock(
|
||||||
|
lock,
|
||||||
|
target_pr_number=PR_A,
|
||||||
|
target_head_sha=HEAD_688,
|
||||||
|
pr_live=_open_pr(PR_A, HEAD_688),
|
||||||
|
)
|
||||||
|
self.assertEqual(c["classification"], srdl.CLASS_TERMINAL_ACTIVE_SAME_HEAD)
|
||||||
|
self.assertFalse(c["mark_final_allowed"])
|
||||||
|
self.assertTrue(c["correction_eligible"])
|
||||||
|
|
||||||
|
def test_precise_failure_includes_durable_handoff(self):
|
||||||
|
lock = _lock([RC_688])
|
||||||
|
c = srdl.classify_review_decision_lock(
|
||||||
|
lock, target_pr_number=PR_A, target_head_sha=HEAD_688
|
||||||
|
)
|
||||||
|
fail = srdl.build_precise_mark_final_failure(c)
|
||||||
|
self.assertIn("exact_next_action", fail)
|
||||||
|
self.assertIn("durable_issue_handoff", fail)
|
||||||
|
self.assertTrue(fail["durable_issue_handoff"]["required"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPr692Sequence(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False)
|
||||||
|
self._env.start()
|
||||||
|
self._prof = patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||||
|
)
|
||||||
|
self._prof.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._prof.stop()
|
||||||
|
self._env.stop()
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_mark_final_692_succeeds_with_open_688_terminal(self):
|
||||||
|
_seed([RC_688], writer_pid=25883, session_pid=60615)
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||||
|
):
|
||||||
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=PR_B,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
self.assertEqual(lock["ready_pr_number"], PR_B)
|
||||||
|
self.assertEqual(
|
||||||
|
srdl.normalize_head_sha(lock["ready_expected_head_sha"]), HEAD_692
|
||||||
|
)
|
||||||
|
# Foreign terminal history preserved (non-destructive isolation).
|
||||||
|
self.assertEqual(lock["live_mutations"][0]["pr_number"], PR_A)
|
||||||
|
|
||||||
|
def test_mark_final_idempotent_same_binding(self):
|
||||||
|
_seed(
|
||||||
|
[RC_688],
|
||||||
|
final_review_decision_ready=True,
|
||||||
|
ready_pr_number=PR_B,
|
||||||
|
ready_action="approve",
|
||||||
|
ready_expected_head_sha=HEAD_692,
|
||||||
|
ready_remote="prgs",
|
||||||
|
ready_org="Scaled-Tech-Consulting",
|
||||||
|
ready_repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||||
|
):
|
||||||
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=PR_B,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["marked_ready"])
|
||||||
|
self.assertTrue(result.get("idempotent"))
|
||||||
|
|
||||||
|
def test_cleanup_still_forbidden_while_688_open(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
assessment = srdl.assess_stale_review_decision_lock(
|
||||||
|
mcp_server._load_review_decision_lock(),
|
||||||
|
pr_live=_open_pr(PR_A, HEAD_688),
|
||||||
|
active_profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment["cleanup_allowed"])
|
||||||
|
self.assertFalse(assessment["is_moot"])
|
||||||
|
|
||||||
|
def test_correction_cannot_unlock_different_pr(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
r = mcp_server.gitea_authorize_review_correction(
|
||||||
|
prior_review_id=REVIEW_ID_A,
|
||||||
|
prior_review_state="request_changes",
|
||||||
|
reason="try unlock 692",
|
||||||
|
operator_authorized=True,
|
||||||
|
target_pr_number=PR_B,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(r["authorized"])
|
||||||
|
self.assertTrue(any("different PR" in x for x in r["reasons"]))
|
||||||
|
|
||||||
|
def test_scoped_correction_for_same_pr_posts_audit(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
with patch(
|
||||||
|
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch("mcp_server.api_request", return_value={"id": 99901}):
|
||||||
|
r = mcp_server.gitea_authorize_review_correction(
|
||||||
|
prior_review_id=REVIEW_ID_A,
|
||||||
|
prior_review_state="request_changes",
|
||||||
|
reason="mistaken RC wording",
|
||||||
|
operator_authorized=True,
|
||||||
|
target_pr_number=PR_A,
|
||||||
|
expected_head_sha=HEAD_688,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(r["authorized"], r.get("reasons"))
|
||||||
|
self.assertEqual(r.get("correction_pr_number"), PR_A)
|
||||||
|
self.assertEqual(
|
||||||
|
r.get("audit_comment_id"),
|
||||||
|
99901,
|
||||||
|
r.get("audit_comment_skipped") or r.get("audit_comment_error") or r,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_diagnose_foreign_terminal_next_action(self):
|
||||||
|
_seed([RC_688])
|
||||||
|
with patch(
|
||||||
|
"mcp_server._authenticated_username", return_value="sysadmin"
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._auth", return_value="Basic dGVzdDp0ZXN0"
|
||||||
|
), patch("mcp_server.api_request", return_value=_open_pr(PR_A, HEAD_688)):
|
||||||
|
d = mcp_server.gitea_diagnose_review_decision_lock(
|
||||||
|
target_pr_number=PR_B,
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(d["success"], d.get("reasons"))
|
||||||
|
self.assertEqual(d["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL)
|
||||||
|
self.assertTrue(d["mark_final_allowed"])
|
||||||
|
self.assertIn("mark_final", d["exact_next_action"])
|
||||||
|
self.assertTrue(d["correction_as_generic_unlock_forbidden"])
|
||||||
|
|
||||||
|
def test_approval_ledger_unique_and_bound_after_sequence(self):
|
||||||
|
"""After mark_final + record mutation, ledger binds unique approve to 692 head."""
|
||||||
|
_seed([RC_688])
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"head_sha": HEAD_692, "eligible": True},
|
||||||
|
):
|
||||||
|
marked = mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=PR_B,
|
||||||
|
action="approve",
|
||||||
|
expected_head_sha=HEAD_692,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(marked["marked_ready"])
|
||||||
|
mcp_server.record_live_review_mutation(PR_B, "approve", REVIEW_ID_B)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
terminals = [
|
||||||
|
m
|
||||||
|
for m in lock["live_mutations"]
|
||||||
|
if m.get("action") in srdl.TERMINAL_REVIEW_ACTIONS
|
||||||
|
and m.get("pr_number") == PR_B
|
||||||
|
]
|
||||||
|
self.assertEqual(len(terminals), 1)
|
||||||
|
self.assertEqual(terminals[0]["review_id"], REVIEW_ID_B)
|
||||||
|
self.assertEqual(
|
||||||
|
srdl.normalize_head_sha(terminals[0].get("head_sha")), HEAD_692
|
||||||
|
)
|
||||||
|
# Same-head duplicate still blocked.
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.prior_live_mutations_block_boundary(
|
||||||
|
lock, pr_number=PR_B, expected_head_sha=HEAD_692
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionRestartWriterPid(unittest.TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._save_review_decision_lock(None)
|
||||||
|
review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
|
def test_writer_pid_change_does_not_inherit_foreign_block(self):
|
||||||
|
# session_pid 60615 origin, writer later 25883 — still foreign isolation.
|
||||||
|
_seed([RC_688], session_pid=60615, writer_pid=25883)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
PR_B, "mark_ready", expected_head_sha=HEAD_692
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
"""Regression tests for the PR #703 REQUEST_CHANGES findings F1–F6 (#702).
|
||||||
|
|
||||||
|
Formal review at head 889931d independently reproduced six defects:
|
||||||
|
|
||||||
|
F1 — stale-binding recovery ran only AFTER the terminal launcher probe in
|
||||||
|
``gitea_resolve_task_capability``; a worktree removed mid-session wedged
|
||||||
|
every mutation-task resolution on 'missing cwd' before recovery.
|
||||||
|
F2 — ``_resolve_preflight_workspace_path`` skipped the existence checks the
|
||||||
|
canonical mutation context applies; a dead binding could produce empty
|
||||||
|
porcelain and read as a clean workspace.
|
||||||
|
F3 — ``orphaned_expired_superseded_head`` cleanup accepted unknown
|
||||||
|
``worktree_exists``/``worktree_clean`` evidence.
|
||||||
|
F4 — the 4-hour session-state TTL silently discarded recovery-critical
|
||||||
|
crash-orphan shadow evidence.
|
||||||
|
F5 — the single same-profile shadow slot let concurrent sessions overwrite
|
||||||
|
each other's crash evidence.
|
||||||
|
F6 — the environment was cleared BEFORE the audit record was persisted, and
|
||||||
|
audit-write failure was swallowed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_session_state as mss # noqa: E402
|
||||||
|
import namespace_workspace_binding as nwb # noqa: E402
|
||||||
|
import reviewer_pr_lease as leases # noqa: E402
|
||||||
|
import stale_binding_recovery as sbr # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
|
||||||
|
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
OLD_HEAD = "b" * 40
|
||||||
|
NEW_HEAD = "6" * 40
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"version": 2,
|
||||||
|
"contexts": {
|
||||||
|
"ctx": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
|
||||||
|
"gitea.pr.create", "gitea.branch.push",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||||
|
],
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class _CapabilityHarness(unittest.TestCase):
|
||||||
|
"""Shared config/env plumbing for resolve-capability integration tests."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.example.com",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
self._remotes.start()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(CONFIG))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._remotes.stop()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _env(self, **extra):
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
|
}
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def _deleted_worktree(self) -> str:
|
||||||
|
path = tempfile.mkdtemp(prefix="gitea-removed-worktree-")
|
||||||
|
shutil.rmtree(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestF1RecoveryBeforeTerminalProbe(_CapabilityHarness):
|
||||||
|
"""F1: recovery must run before the terminal cwd probe can wedge."""
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
|
def test_removed_worktree_recovers_before_probe(self, _auth, _api):
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
env = self._env(
|
||||||
|
GITEA_ACTIVE_WORKTREE=missing,
|
||||||
|
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||||
|
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
# The provably-stale binding was cleared before the probe ran.
|
||||||
|
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||||
|
self.assertFalse(res.get("terminal_launcher_unhealthy"))
|
||||||
|
report = res.get("stale_binding_recovery") or {}
|
||||||
|
self.assertEqual(
|
||||||
|
report.get("classification"),
|
||||||
|
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
)
|
||||||
|
self.assertTrue(report.get("recovery_performed"))
|
||||||
|
self.assertTrue(res.get("allowed_in_current_session"))
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
|
def test_probe_block_still_carries_recovery_report(self, _auth, _api):
|
||||||
|
# Recovery disabled (test mode without the force flag) preserves the
|
||||||
|
# fail-closed probe block, but the block must now carry the
|
||||||
|
# classification evidence instead of hiding it.
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
env = self._env(
|
||||||
|
GITEA_ACTIVE_WORKTREE=missing,
|
||||||
|
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||||
|
self.assertTrue(res.get("terminal_launcher_unhealthy"))
|
||||||
|
report = res.get("stale_binding_recovery") or {}
|
||||||
|
self.assertEqual(
|
||||||
|
report.get("classification"),
|
||||||
|
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
)
|
||||||
|
self.assertFalse(report.get("recovery_performed"))
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
|
||||||
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
|
def test_unverified_binding_is_never_cleared_by_reordering(self, _auth, _api):
|
||||||
|
# F1 must not weaken authorization: an existing, uncorroborated
|
||||||
|
# binding survives resolution untouched even with recovery forced.
|
||||||
|
existing = tempfile.mkdtemp(prefix="gitea-existing-worktree-")
|
||||||
|
self.addCleanup(shutil.rmtree, existing, ignore_errors=True)
|
||||||
|
env = self._env(
|
||||||
|
GITEA_ACTIVE_WORKTREE=existing,
|
||||||
|
GITEA_FORCE_TERMINAL_PROBE="1",
|
||||||
|
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_pr", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), existing)
|
||||||
|
report = res.get("stale_binding_recovery") or {}
|
||||||
|
self.assertFalse(report.get("recovery_performed"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestF2PreflightPathVerification(_CapabilityHarness):
|
||||||
|
"""F2: preflight and mutation contexts verify resolved paths alike."""
|
||||||
|
|
||||||
|
def test_preflight_and_mutation_context_agree_on_dead_binding(self):
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
env = self._env(GITEA_ACTIVE_WORKTREE=missing)
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
preflight = mcp_server._resolve_preflight_workspace_path(None)
|
||||||
|
mutation = mcp_server._resolve_namespace_mutation_context(None)
|
||||||
|
self.assertEqual(preflight, mutation["workspace_path"])
|
||||||
|
self.assertNotEqual(preflight, os.path.realpath(missing))
|
||||||
|
|
||||||
|
def test_missing_explicit_worktree_never_reads_clean(self):
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
with patch.dict(os.environ, self._env()):
|
||||||
|
porcelain = mcp_server._get_workspace_porcelain(worktree_path=missing)
|
||||||
|
self.assertEqual(
|
||||||
|
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||||
|
)
|
||||||
|
# The sentinel parses as a tracked dirty entry — never clean.
|
||||||
|
self.assertTrue(mcp_server._parse_porcelain_entries(porcelain))
|
||||||
|
|
||||||
|
def test_workspace_deleted_during_evaluation_never_reads_clean(self):
|
||||||
|
# TOCTOU: resolution succeeded, then the path vanished before git ran.
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
with patch.dict(os.environ, self._env()):
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_resolve_preflight_workspace_path",
|
||||||
|
return_value=missing,
|
||||||
|
):
|
||||||
|
porcelain = mcp_server._get_workspace_porcelain()
|
||||||
|
self.assertEqual(
|
||||||
|
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_git_failure_never_reads_clean(self):
|
||||||
|
class _Failed:
|
||||||
|
returncode = 128
|
||||||
|
stdout = ""
|
||||||
|
stderr = "fatal: not a git repository"
|
||||||
|
|
||||||
|
with patch.dict(os.environ, self._env()):
|
||||||
|
with patch.object(
|
||||||
|
mcp_server.subprocess, "run", return_value=_Failed()
|
||||||
|
):
|
||||||
|
porcelain = mcp_server._get_workspace_porcelain()
|
||||||
|
self.assertEqual(
|
||||||
|
porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_symlinked_binding_to_deleted_target_is_demoted(self):
|
||||||
|
target = tempfile.mkdtemp(prefix="gitea-symlink-target-")
|
||||||
|
holder = tempfile.mkdtemp(prefix="gitea-symlink-holder-")
|
||||||
|
self.addCleanup(shutil.rmtree, holder, ignore_errors=True)
|
||||||
|
link = os.path.join(holder, "worktree-link")
|
||||||
|
os.symlink(target, link)
|
||||||
|
shutil.rmtree(target)
|
||||||
|
demotions: list[str] = []
|
||||||
|
_path, source = nwb.resolve_namespace_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
process_project_root=os.getcwd(),
|
||||||
|
env={"GITEA_ACTIVE_WORKTREE": link},
|
||||||
|
demotions=demotions,
|
||||||
|
verify_paths=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(source, "MCP server process root (default)")
|
||||||
|
self.assertEqual(len(demotions), 1)
|
||||||
|
|
||||||
|
def test_dead_binding_falls_through_to_live_role_binding(self):
|
||||||
|
# Path changes during evaluation: the dead candidate demotes at
|
||||||
|
# verification time and never resurrects over the live fallback.
|
||||||
|
alive = tempfile.mkdtemp(prefix="gitea-alive-worktree-")
|
||||||
|
self.addCleanup(shutil.rmtree, alive, ignore_errors=True)
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
path, source = nwb.resolve_namespace_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
process_project_root=os.getcwd(),
|
||||||
|
env={
|
||||||
|
"GITEA_ACTIVE_WORKTREE": missing,
|
||||||
|
"GITEA_REVIEWER_WORKTREE": alive,
|
||||||
|
},
|
||||||
|
verify_paths=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(path, os.path.realpath(alive))
|
||||||
|
self.assertEqual(
|
||||||
|
source, "GITEA_REVIEWER_WORKTREE environment variable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestF3AffirmativeWorktreeEvidence(unittest.TestCase):
|
||||||
|
"""F3: unknown worktree evidence must fail closed for crash orphans."""
|
||||||
|
|
||||||
|
def _comments(self) -> list[dict]:
|
||||||
|
stale = _now() - timedelta(hours=3)
|
||||||
|
body = leases.format_lease_body(
|
||||||
|
repo=REPO,
|
||||||
|
pr_number=701,
|
||||||
|
issue_number=699,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="65664-4f248d213d60",
|
||||||
|
worktree="branches/review-pr-654",
|
||||||
|
phase="validation-start",
|
||||||
|
candidate_head=OLD_HEAD,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="2" * 40,
|
||||||
|
last_activity=stale,
|
||||||
|
expires_at=stale + timedelta(minutes=120),
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": 11131,
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": "sysadmin"},
|
||||||
|
"author": "sysadmin",
|
||||||
|
"created_at": stale.isoformat(),
|
||||||
|
"updated_at": stale.isoformat(),
|
||||||
|
}]
|
||||||
|
|
||||||
|
def _assess(self, **kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
pr_number=701,
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
formal_reviews=[],
|
||||||
|
repo=REPO,
|
||||||
|
expected_repo=REPO,
|
||||||
|
requesting_session_id="fresh-controller",
|
||||||
|
controller_recovery_authorized=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||||
|
self._comments(), **defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_worktree_evidence_fails_closed(self):
|
||||||
|
result = self._assess(worktree_exists=None, worktree_clean=None)
|
||||||
|
self.assertEqual(
|
||||||
|
result["classification"], "orphaned_expired_superseded_head"
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertIn(
|
||||||
|
"affirmative safe evidence",
|
||||||
|
" ".join(result["fail_closed_reasons"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_cleanliness_with_existing_worktree_fails_closed(self):
|
||||||
|
result = self._assess(worktree_exists=True, worktree_clean=None)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_affirmative_clean_worktree_is_eligible(self):
|
||||||
|
result = self._assess(worktree_exists=True, worktree_clean=True)
|
||||||
|
self.assertEqual(
|
||||||
|
result["classification"], "orphaned_expired_superseded_head"
|
||||||
|
)
|
||||||
|
self.assertTrue(result["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_affirmative_absent_worktree_is_eligible(self):
|
||||||
|
result = self._assess(worktree_exists=False, worktree_clean=None)
|
||||||
|
self.assertTrue(result["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_matrix_matches_diagnosis_gate(self):
|
||||||
|
# The cleanup matrix and the diagnosis path must agree: unknown
|
||||||
|
# evidence yields acquire-only, affirmative evidence yields cleanup.
|
||||||
|
diagnosis_unknown = leases.diagnose_reviewer_pr_lease_handoff(
|
||||||
|
self._comments(),
|
||||||
|
pr_number=701,
|
||||||
|
current_session_id=None,
|
||||||
|
current_reviewer_identity="fresh-reviewer",
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
formal_reviews=[],
|
||||||
|
owner_process_alive=False,
|
||||||
|
worktree_exists=None,
|
||||||
|
worktree_clean=None,
|
||||||
|
)
|
||||||
|
assess_unknown = self._assess(worktree_exists=None, worktree_clean=None)
|
||||||
|
self.assertNotEqual(
|
||||||
|
diagnosis_unknown["next_action"],
|
||||||
|
"cleanup_obsolete_reviewer_comment_lease",
|
||||||
|
)
|
||||||
|
self.assertFalse(assess_unknown["cleanup_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestF4CrashShadowDurability(unittest.TestCase):
|
||||||
|
"""F4: crash-orphan evidence survives the former 4h TTL boundary."""
|
||||||
|
|
||||||
|
SID = "82984-abcabcabcabc"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self._env = patch.dict(
|
||||||
|
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
|
||||||
|
)
|
||||||
|
self._env.start()
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._env.stop()
|
||||||
|
self._dir.cleanup()
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
|
||||||
|
def _age_record(self, kind: str, hours: float, instance_id: str | None = None):
|
||||||
|
path = mss.state_file_path(kind=kind, instance_id=instance_id)
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
envelope = json.load(fh)
|
||||||
|
stamp = (
|
||||||
|
(_now() - timedelta(hours=hours)).isoformat().replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
envelope["recorded_at"] = stamp
|
||||||
|
envelope["updated_at"] = stamp
|
||||||
|
envelope["payload"]["recorded_at"] = stamp
|
||||||
|
envelope["payload"]["updated_at"] = stamp
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(envelope, fh)
|
||||||
|
|
||||||
|
def _save_shadow(self) -> None:
|
||||||
|
mss.save_state(
|
||||||
|
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||||
|
instance_id=self.SID,
|
||||||
|
payload={"session_id": self.SID, "owner_pid": os.getpid()},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_shadow(self):
|
||||||
|
return mss.load_state(
|
||||||
|
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shadow_loads_before_former_ttl_boundary(self):
|
||||||
|
self._save_shadow()
|
||||||
|
self._age_record(
|
||||||
|
mss.KIND_REVIEWER_SESSION_LEASE, hours=3.9, instance_id=self.SID
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self._load_shadow())
|
||||||
|
|
||||||
|
def test_shadow_loads_at_former_ttl_boundary(self):
|
||||||
|
self._save_shadow()
|
||||||
|
self._age_record(
|
||||||
|
mss.KIND_REVIEWER_SESSION_LEASE, hours=4.0, instance_id=self.SID
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self._load_shadow())
|
||||||
|
|
||||||
|
def test_shadow_loads_long_after_former_ttl_boundary(self):
|
||||||
|
self._save_shadow()
|
||||||
|
self._age_record(
|
||||||
|
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||||
|
)
|
||||||
|
record = self._load_shadow()
|
||||||
|
self.assertIsNotNone(record)
|
||||||
|
self.assertEqual(record.get("session_id"), self.SID)
|
||||||
|
|
||||||
|
def test_stale_binding_audit_record_is_also_durable(self):
|
||||||
|
mss.save_state(
|
||||||
|
kind=mss.KIND_STALE_BINDING_RECOVERY,
|
||||||
|
payload={"cleared_env": "GITEA_ACTIVE_WORKTREE"},
|
||||||
|
)
|
||||||
|
self._age_record(mss.KIND_STALE_BINDING_RECOVERY, hours=27.0)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_recovery_kinds_keep_ttl(self):
|
||||||
|
mss.save_state(
|
||||||
|
kind=mss.KIND_WORKFLOW_LOAD, payload={"workflow_hash": "abc"}
|
||||||
|
)
|
||||||
|
self._age_record(mss.KIND_WORKFLOW_LOAD, hours=5.0)
|
||||||
|
self.assertIsNone(mss.load_state(kind=mss.KIND_WORKFLOW_LOAD))
|
||||||
|
|
||||||
|
def test_sanctioned_clear_is_the_terminal_reconciliation(self):
|
||||||
|
self._save_shadow()
|
||||||
|
self._age_record(
|
||||||
|
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||||
|
)
|
||||||
|
mss.clear_state(
|
||||||
|
kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID
|
||||||
|
)
|
||||||
|
self.assertIsNone(self._load_shadow())
|
||||||
|
self.assertEqual(
|
||||||
|
mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE), []
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_crash_orphan_recovery_works_after_former_ttl(self):
|
||||||
|
# End to end: a crashed session's shadow still proves owner exit a
|
||||||
|
# day later.
|
||||||
|
leases.record_session_lease(
|
||||||
|
{
|
||||||
|
"pr_number": 701,
|
||||||
|
"session_id": self.SID,
|
||||||
|
"worktree": "branches/review-pr-654",
|
||||||
|
"candidate_head": OLD_HEAD,
|
||||||
|
"repo": REPO,
|
||||||
|
"comment_id": 11131,
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"reviewer_identity": "sysadmin",
|
||||||
|
"phase": "claimed",
|
||||||
|
},
|
||||||
|
lease_provenance={"source": "acquire", "comment_id": 11131},
|
||||||
|
)
|
||||||
|
leases._SESSION_LEASE = None # simulated crash: no sanctioned clear
|
||||||
|
self._age_record(
|
||||||
|
mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID
|
||||||
|
)
|
||||||
|
shadow = leases.load_session_lease_shadow(session_id=self.SID)
|
||||||
|
self.assertIsNotNone(shadow)
|
||||||
|
verdict = leases.assess_crashed_session_lease_orphan(
|
||||||
|
shadow,
|
||||||
|
lease={"session_id": self.SID},
|
||||||
|
current_pid=os.getpid() + 1,
|
||||||
|
pid_alive=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["orphan_evidence"])
|
||||||
|
self.assertIs(verdict["owner_process_alive"], False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestF5ConcurrentSessionShadows(unittest.TestCase):
|
||||||
|
"""F5: concurrent same-profile sessions keep distinct shadow records."""
|
||||||
|
|
||||||
|
SID_A = "11111-aaaaaaaaaaaa"
|
||||||
|
SID_B = "22222-bbbbbbbbbbbb"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self._env = patch.dict(
|
||||||
|
os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name}
|
||||||
|
)
|
||||||
|
self._env.start()
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._env.stop()
|
||||||
|
self._dir.cleanup()
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
|
||||||
|
def _lease(self, session_id: str, pr_number: int, head: str) -> dict:
|
||||||
|
return {
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"session_id": session_id,
|
||||||
|
"worktree": f"branches/review-pr-{pr_number}-{session_id[:5]}",
|
||||||
|
"candidate_head": head,
|
||||||
|
"repo": REPO,
|
||||||
|
"comment_id": 11221,
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"reviewer_identity": "sysadmin",
|
||||||
|
"phase": "claimed",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _record(self, lease: dict) -> None:
|
||||||
|
leases.record_session_lease(
|
||||||
|
lease,
|
||||||
|
lease_provenance={"source": "acquire", "comment_id": 11221},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_interleaved_sessions_do_not_overwrite_each_other(self):
|
||||||
|
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||||
|
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||||
|
# Session A heartbeats again after B wrote.
|
||||||
|
updated_a = self._lease(self.SID_A, 703, OLD_HEAD)
|
||||||
|
updated_a["phase"] = "validation-start"
|
||||||
|
self._record(updated_a)
|
||||||
|
|
||||||
|
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||||
|
shadow_b = leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||||
|
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
|
||||||
|
self.assertEqual(shadow_a.get("pr_number"), 703)
|
||||||
|
self.assertEqual(shadow_a.get("phase"), "validation-start")
|
||||||
|
self.assertEqual(shadow_b.get("session_id"), self.SID_B)
|
||||||
|
self.assertEqual(shadow_b.get("pr_number"), 701)
|
||||||
|
self.assertEqual(shadow_b.get("candidate_head"), NEW_HEAD)
|
||||||
|
|
||||||
|
def test_shadow_lookup_never_misattributes(self):
|
||||||
|
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||||
|
self.assertIsNone(
|
||||||
|
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||||
|
)
|
||||||
|
verdict = leases.assess_crashed_session_lease_orphan(
|
||||||
|
leases.load_session_lease_shadow(session_id=self.SID_A),
|
||||||
|
lease={"session_id": self.SID_B},
|
||||||
|
pid_alive=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["orphan_evidence"])
|
||||||
|
|
||||||
|
def test_clearing_one_session_preserves_the_other(self):
|
||||||
|
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||||
|
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||||
|
# Sanctioned clear of B (the currently recorded in-memory lease).
|
||||||
|
leases.clear_session_lease()
|
||||||
|
self.assertIsNone(
|
||||||
|
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||||
|
)
|
||||||
|
shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||||
|
self.assertIsNotNone(shadow_a)
|
||||||
|
self.assertEqual(shadow_a.get("session_id"), self.SID_A)
|
||||||
|
|
||||||
|
def test_reconnected_process_recovers_by_session_id(self):
|
||||||
|
self._record(self._lease(self.SID_A, 703, OLD_HEAD))
|
||||||
|
self._record(self._lease(self.SID_B, 701, NEW_HEAD))
|
||||||
|
# Simulated crash + reconnect: in-memory state is gone, durable
|
||||||
|
# records remain enumerable and individually attributable.
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE)
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(r.get("session_id") for r in records),
|
||||||
|
sorted([self.SID_A, self.SID_B]),
|
||||||
|
)
|
||||||
|
shadow = leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||||
|
self.assertEqual(shadow.get("pr_number"), 701)
|
||||||
|
|
||||||
|
def test_legacy_single_slot_record_still_resolves_by_session_id(self):
|
||||||
|
# Upgrade path: a record written by pre-F5 code (no instance key)
|
||||||
|
# remains usable when its payload names the requested session.
|
||||||
|
mss.save_state(
|
||||||
|
kind=mss.KIND_REVIEWER_SESSION_LEASE,
|
||||||
|
payload={"session_id": self.SID_A, "owner_pid": os.getpid()},
|
||||||
|
)
|
||||||
|
shadow = leases.load_session_lease_shadow(session_id=self.SID_A)
|
||||||
|
self.assertIsNotNone(shadow)
|
||||||
|
self.assertIsNone(
|
||||||
|
leases.load_session_lease_shadow(session_id=self.SID_B)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestF6AuditBeforeClear(_CapabilityHarness):
|
||||||
|
"""F6: the durable audit record is persisted before the env clears."""
|
||||||
|
|
||||||
|
def _recovery_env(self, missing: str) -> dict:
|
||||||
|
return self._env(
|
||||||
|
GITEA_ACTIVE_WORKTREE=missing,
|
||||||
|
GITEA_FORCE_STALE_BINDING_RECOVERY="1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_audit_write_failure_blocks_the_clear(self):
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||||
|
with patch.object(
|
||||||
|
mss, "save_state", side_effect=OSError("disk full")
|
||||||
|
):
|
||||||
|
report = mcp_server._assess_stale_active_binding(
|
||||||
|
auto_recover=True
|
||||||
|
)
|
||||||
|
self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), missing)
|
||||||
|
self.assertFalse(report["recovery_performed"])
|
||||||
|
self.assertIs(report.get("audit_persisted"), False)
|
||||||
|
self.assertTrue(report.get("audit_write_failed"))
|
||||||
|
self.assertIn("NOT cleared", " ".join(report.get("reasons") or []))
|
||||||
|
|
||||||
|
def test_retry_after_audit_failure_recovers(self):
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||||
|
with patch.object(
|
||||||
|
mss, "save_state", side_effect=OSError("disk full")
|
||||||
|
):
|
||||||
|
first = mcp_server._assess_stale_active_binding(
|
||||||
|
auto_recover=True
|
||||||
|
)
|
||||||
|
self.assertFalse(first["recovery_performed"])
|
||||||
|
# Persistence recovered; the retry clears with a durable audit.
|
||||||
|
second = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||||
|
self.assertTrue(second["recovery_performed"])
|
||||||
|
self.assertIs(second.get("audit_persisted"), True)
|
||||||
|
self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ)
|
||||||
|
audit = mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY)
|
||||||
|
self.assertIsNotNone(audit)
|
||||||
|
self.assertEqual(audit.get("recovery_status"), "performed")
|
||||||
|
self.assertEqual(audit.get("cleared_value"), missing)
|
||||||
|
|
||||||
|
def test_audit_record_exists_before_env_clear(self):
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
observed: list[tuple[str | None, str | None]] = []
|
||||||
|
real_save = mss.save_state
|
||||||
|
|
||||||
|
def _spy(**kwargs):
|
||||||
|
observed.append(
|
||||||
|
(
|
||||||
|
(kwargs.get("payload") or {}).get("recovery_status"),
|
||||||
|
os.environ.get("GITEA_ACTIVE_WORKTREE"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return real_save(**kwargs)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||||
|
with patch.object(mss, "save_state", side_effect=_spy):
|
||||||
|
report = mcp_server._assess_stale_active_binding(
|
||||||
|
auto_recover=True
|
||||||
|
)
|
||||||
|
self.assertTrue(report["recovery_performed"])
|
||||||
|
self.assertGreaterEqual(len(observed), 2)
|
||||||
|
pending_status, env_at_pending = observed[0]
|
||||||
|
performed_status, env_at_performed = observed[-1]
|
||||||
|
# Ordering proof: the binding was still present while the pending
|
||||||
|
# audit persisted, and gone by the time the performed status wrote.
|
||||||
|
self.assertEqual(pending_status, "pending")
|
||||||
|
self.assertEqual(env_at_pending, missing)
|
||||||
|
self.assertEqual(performed_status, "performed")
|
||||||
|
self.assertIsNone(env_at_performed)
|
||||||
|
|
||||||
|
def test_recovery_is_idempotent_after_success(self):
|
||||||
|
missing = self._deleted_worktree()
|
||||||
|
with patch.dict(os.environ, self._recovery_env(missing)):
|
||||||
|
first = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||||
|
second = mcp_server._assess_stale_active_binding(auto_recover=True)
|
||||||
|
self.assertTrue(first["recovery_performed"])
|
||||||
|
self.assertEqual(second["classification"], sbr.CLASSIFICATION_UNBOUND)
|
||||||
|
self.assertFalse(second["recovery_performed"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
"""Regression tests for #702: stale runtime binding + orphaned durable lease.
|
||||||
|
|
||||||
|
Reproduces the PR #701 incident (lease 65664-4f248d213d60, comments
|
||||||
|
11129/11131): the MCP daemon crashed without teardown, destroying the
|
||||||
|
in-memory session lease while the durable comment lease survived, and the
|
||||||
|
auto-reconnected daemon inherited a stale ``GITEA_ACTIVE_WORKTREE`` binding
|
||||||
|
(``branches/review-pr-654``) from its parent environment.
|
||||||
|
|
||||||
|
Covers the five mandated scenarios:
|
||||||
|
|
||||||
|
1. lost in-session leases — durable shadow survives a crash and yields
|
||||||
|
provable owner-process evidence
|
||||||
|
2. old-head leases — expired superseded-head lease with no terminal
|
||||||
|
review becomes recoverable only with orphan evidence + controller authority
|
||||||
|
3. runtime/worktree mismatch — env bindings to deleted worktrees are demoted,
|
||||||
|
never silently bound
|
||||||
|
4. managed reconnect — boot-inherited uncorroborated bindings are
|
||||||
|
surfaced for managed recovery; provably stale ones are clear-eligible
|
||||||
|
5. safe expiry — pre-expiry stays fail-closed wait (no steal);
|
||||||
|
canonical expiry unlocks acquisition and guarded cleanup
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import namespace_workspace_binding as nwb # noqa: E402
|
||||||
|
import reviewer_pr_lease as leases # noqa: E402
|
||||||
|
import stale_binding_recovery as sbr # noqa: E402
|
||||||
|
|
||||||
|
# PR #701 incident fixtures.
|
||||||
|
OLD_HEAD = "b4d0cb22e452a07c8e816745c704ddb0f43edf0b"
|
||||||
|
NEW_HEAD = "6b675f5c834b41f9d74e8a54294ff44dddf28ae4"
|
||||||
|
CRASHED_SESSION = "65664-4f248d213d60"
|
||||||
|
LEASE_COMMENT = 11131
|
||||||
|
REPO = "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
PR = 701
|
||||||
|
ISSUE = 699
|
||||||
|
LEASE_WORKTREE = "branches/review-fix-issue-699-structured-auth-mcp-errors"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_comment(
|
||||||
|
*,
|
||||||
|
phase: str = "validation-start",
|
||||||
|
candidate_head: str = OLD_HEAD,
|
||||||
|
session_id: str = CRASHED_SESSION,
|
||||||
|
comment_id: int = LEASE_COMMENT,
|
||||||
|
last_activity: datetime | None = None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
worktree: str = LEASE_WORKTREE,
|
||||||
|
) -> dict:
|
||||||
|
stamp = last_activity or _now()
|
||||||
|
body = leases.format_lease_body(
|
||||||
|
repo=REPO,
|
||||||
|
pr_number=PR,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
reviewer_identity="sysadmin",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id=session_id,
|
||||||
|
worktree=worktree,
|
||||||
|
phase=phase,
|
||||||
|
candidate_head=candidate_head,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="2" * 40,
|
||||||
|
last_activity=stamp,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": comment_id,
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": "sysadmin"},
|
||||||
|
"author": "sysadmin",
|
||||||
|
"created_at": stamp.isoformat(),
|
||||||
|
"updated_at": stamp.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _expired_superseded_comments() -> list[dict]:
|
||||||
|
stale = _now() - timedelta(hours=3)
|
||||||
|
return [_lease_comment(
|
||||||
|
last_activity=stale,
|
||||||
|
expires_at=stale + timedelta(minutes=120),
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_superseded_comments() -> list[dict]:
|
||||||
|
recent = _now() - timedelta(minutes=10)
|
||||||
|
return [_lease_comment(
|
||||||
|
last_activity=recent,
|
||||||
|
expires_at=recent + timedelta(minutes=120),
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLostInSessionLeaseShadow(unittest.TestCase):
|
||||||
|
"""Scenario 1: the durable shadow survives a daemon crash (#702 AC3)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
|
||||||
|
def _record(self) -> dict:
|
||||||
|
return leases.record_session_lease(
|
||||||
|
{
|
||||||
|
"pr_number": PR,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"session_id": CRASHED_SESSION,
|
||||||
|
"reviewer_identity": "sysadmin",
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"worktree": LEASE_WORKTREE,
|
||||||
|
"phase": "claimed",
|
||||||
|
"candidate_head": OLD_HEAD,
|
||||||
|
"repo": REPO,
|
||||||
|
"comment_id": LEASE_COMMENT,
|
||||||
|
},
|
||||||
|
lease_provenance={"source": "acquire", "comment_id": LEASE_COMMENT},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shadow_written_on_record_and_survives_simulated_crash(self):
|
||||||
|
self._record()
|
||||||
|
# Simulated unhandled crash: the in-memory dict dies with the process
|
||||||
|
# but the sanctioned clear path never runs.
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
shadow = leases.load_session_lease_shadow()
|
||||||
|
self.assertIsNotNone(shadow)
|
||||||
|
self.assertEqual(shadow.get("session_id"), CRASHED_SESSION)
|
||||||
|
self.assertEqual(shadow.get("pr_number"), PR)
|
||||||
|
self.assertEqual(int(shadow.get("owner_pid")), os.getpid())
|
||||||
|
|
||||||
|
def test_sanctioned_clear_removes_shadow(self):
|
||||||
|
self._record()
|
||||||
|
leases.clear_session_lease()
|
||||||
|
self.assertIsNone(leases.load_session_lease_shadow())
|
||||||
|
|
||||||
|
def test_orphan_assessment_dead_owner_pid_proves_exit(self):
|
||||||
|
self._record()
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
shadow = leases.load_session_lease_shadow()
|
||||||
|
lease = {"session_id": CRASHED_SESSION}
|
||||||
|
verdict = leases.assess_crashed_session_lease_orphan(
|
||||||
|
shadow, lease=lease, current_pid=os.getpid() + 1, pid_alive=False
|
||||||
|
)
|
||||||
|
self.assertTrue(verdict["orphan_evidence"])
|
||||||
|
self.assertIs(verdict["owner_process_alive"], False)
|
||||||
|
|
||||||
|
def test_orphan_assessment_alive_pid_is_not_ownership_proof(self):
|
||||||
|
self._record()
|
||||||
|
shadow = leases.load_session_lease_shadow()
|
||||||
|
verdict = leases.assess_crashed_session_lease_orphan(
|
||||||
|
shadow,
|
||||||
|
lease={"session_id": CRASHED_SESSION},
|
||||||
|
current_pid=os.getpid() + 1,
|
||||||
|
pid_alive=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["orphan_evidence"])
|
||||||
|
self.assertIsNone(verdict["owner_process_alive"])
|
||||||
|
|
||||||
|
def test_orphan_assessment_session_mismatch_proves_nothing(self):
|
||||||
|
self._record()
|
||||||
|
shadow = leases.load_session_lease_shadow()
|
||||||
|
verdict = leases.assess_crashed_session_lease_orphan(
|
||||||
|
shadow, lease={"session_id": "other-999"}, pid_alive=False
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["orphan_evidence"])
|
||||||
|
self.assertIsNone(verdict["owner_process_alive"])
|
||||||
|
|
||||||
|
def test_orphan_assessment_without_shadow_is_unknown(self):
|
||||||
|
verdict = leases.assess_crashed_session_lease_orphan(
|
||||||
|
None, lease={"session_id": CRASHED_SESSION}
|
||||||
|
)
|
||||||
|
self.assertFalse(verdict["orphan_evidence"])
|
||||||
|
self.assertIsNone(verdict["owner_process_alive"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestOldHeadLeaseCleanup(unittest.TestCase):
|
||||||
|
"""Scenario 2: expired superseded-head lease with no terminal review."""
|
||||||
|
|
||||||
|
def _assess(self, comments, **kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
pr_number=PR,
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
formal_reviews=[],
|
||||||
|
repo=REPO,
|
||||||
|
expected_repo=REPO,
|
||||||
|
requesting_session_id="fresh-controller",
|
||||||
|
controller_recovery_authorized=True,
|
||||||
|
worktree_exists=True,
|
||||||
|
worktree_clean=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||||
|
comments, **defaults
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_orphan_with_full_evidence_is_cleanup_eligible(self):
|
||||||
|
result = self._assess(_expired_superseded_comments())
|
||||||
|
self.assertEqual(result["classification"], "orphaned_expired_superseded_head")
|
||||||
|
self.assertTrue(result["cleanup_allowed"])
|
||||||
|
self.assertIn("phase: released", result["release_body"] or "")
|
||||||
|
self.assertEqual(
|
||||||
|
result["exact_next_action"], "cleanup_obsolete_reviewer_comment_lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_orphan_without_owner_evidence_fails_closed(self):
|
||||||
|
result = self._assess(
|
||||||
|
_expired_superseded_comments(), owner_process_alive=None
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertIn(
|
||||||
|
"provable owner-process-exit evidence",
|
||||||
|
" ".join(result["fail_closed_reasons"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_orphan_without_controller_authority_fails_closed(self):
|
||||||
|
result = self._assess(
|
||||||
|
_expired_superseded_comments(), controller_recovery_authorized=False
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_expired_orphan_with_dirty_worktree_fails_closed(self):
|
||||||
|
result = self._assess(
|
||||||
|
_expired_superseded_comments(),
|
||||||
|
worktree_clean=False,
|
||||||
|
worktree_has_unpreserved_work=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
|
||||||
|
def test_unexpired_superseded_no_terminal_stays_ambiguous_wait(self):
|
||||||
|
# Regression guard: pre-expiry there is still no steal path.
|
||||||
|
result = self._assess(_fresh_superseded_comments())
|
||||||
|
self.assertEqual(result["classification"], "ambiguous_conflicting_evidence")
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
self.assertEqual(result["exact_next_action"], "wait")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntimeWorktreeMismatch(unittest.TestCase):
|
||||||
|
"""Scenario 3: env bindings to deleted worktrees are demoted (#702 AC2)."""
|
||||||
|
|
||||||
|
def test_missing_active_env_binding_is_demoted(self):
|
||||||
|
demotions: list[str] = []
|
||||||
|
missing = "/nonexistent/branches/review-pr-654"
|
||||||
|
path, source = nwb.resolve_namespace_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
process_project_root=os.getcwd(),
|
||||||
|
env={"GITEA_ACTIVE_WORKTREE": missing},
|
||||||
|
demotions=demotions,
|
||||||
|
verify_paths=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(source, "MCP server process root (default)")
|
||||||
|
self.assertEqual(len(demotions), 1)
|
||||||
|
self.assertIn("no longer exists", demotions[0])
|
||||||
|
|
||||||
|
def test_missing_active_falls_through_to_existing_role_env(self):
|
||||||
|
existing = os.getcwd()
|
||||||
|
path, source = nwb.resolve_namespace_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
process_project_root="/tmp",
|
||||||
|
env={
|
||||||
|
"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654",
|
||||||
|
"GITEA_REVIEWER_WORKTREE": existing,
|
||||||
|
},
|
||||||
|
verify_paths=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(path, os.path.realpath(existing))
|
||||||
|
self.assertEqual(source, "GITEA_REVIEWER_WORKTREE environment variable")
|
||||||
|
|
||||||
|
def test_existing_active_env_binding_still_wins(self):
|
||||||
|
existing = os.getcwd()
|
||||||
|
path, source = nwb.resolve_namespace_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
process_project_root="/tmp",
|
||||||
|
env={"GITEA_ACTIVE_WORKTREE": existing},
|
||||||
|
verify_paths=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(path, os.path.realpath(existing))
|
||||||
|
self.assertEqual(source, "GITEA_ACTIVE_WORKTREE environment variable")
|
||||||
|
|
||||||
|
def test_explicit_worktree_path_argument_is_never_demoted(self):
|
||||||
|
missing = "/nonexistent/branches/explicit"
|
||||||
|
path, source = nwb.resolve_namespace_workspace(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=missing,
|
||||||
|
process_project_root="/tmp",
|
||||||
|
env={},
|
||||||
|
verify_paths=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(source, "worktree_path argument")
|
||||||
|
|
||||||
|
def test_mutation_context_reports_demotion_in_ignored_bindings(self):
|
||||||
|
ctx = nwb.resolve_namespace_mutation_context(
|
||||||
|
role_kind="reviewer",
|
||||||
|
worktree_path=None,
|
||||||
|
process_project_root=os.getcwd(),
|
||||||
|
env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"},
|
||||||
|
)
|
||||||
|
joined = " ".join(ctx["ignored_bindings"])
|
||||||
|
self.assertIn("stale binding, #702", joined)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagedReconnectRecovery(unittest.TestCase):
|
||||||
|
"""Scenario 4: boot-inherited bindings and the sanctioned recovery plan."""
|
||||||
|
|
||||||
|
def test_uncorroborated_inherited_reviewer_binding_is_unverified(self):
|
||||||
|
classification = sbr.classify_active_worktree_binding(
|
||||||
|
active_value=os.getcwd(),
|
||||||
|
role_env_value=None,
|
||||||
|
session_lease_worktree=None,
|
||||||
|
boot_inherited=True,
|
||||||
|
path_exists=True,
|
||||||
|
role_kind="reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||||
|
)
|
||||||
|
self.assertFalse(classification["clear_eligible"])
|
||||||
|
plan = sbr.plan_recovery(classification)
|
||||||
|
self.assertFalse(plan["clear_allowed"])
|
||||||
|
self.assertIn("managed", plan["exact_next_action"])
|
||||||
|
self.assertIn("do not clear or rewrite", plan["exact_next_action"])
|
||||||
|
|
||||||
|
def test_missing_path_binding_is_provably_stale_and_clear_eligible(self):
|
||||||
|
classification = sbr.classify_active_worktree_binding(
|
||||||
|
active_value="/nonexistent/branches/review-pr-654",
|
||||||
|
boot_inherited=True,
|
||||||
|
path_exists=False,
|
||||||
|
role_kind="reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classification["classification"],
|
||||||
|
sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH,
|
||||||
|
)
|
||||||
|
plan = sbr.plan_recovery(classification)
|
||||||
|
self.assertTrue(plan["clear_allowed"])
|
||||||
|
|
||||||
|
def test_inherited_binding_superseded_by_session_lease_is_clear_eligible(self):
|
||||||
|
classification = sbr.classify_active_worktree_binding(
|
||||||
|
active_value="/tmp",
|
||||||
|
session_lease_worktree=os.getcwd(),
|
||||||
|
boot_inherited=True,
|
||||||
|
path_exists=True,
|
||||||
|
role_kind="reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classification["classification"],
|
||||||
|
sbr.CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE,
|
||||||
|
)
|
||||||
|
self.assertTrue(sbr.plan_recovery(classification)["clear_allowed"])
|
||||||
|
|
||||||
|
def test_post_boot_binding_conflict_is_surfaced_not_cleared(self):
|
||||||
|
classification = sbr.classify_active_worktree_binding(
|
||||||
|
active_value="/tmp",
|
||||||
|
session_lease_worktree=os.getcwd(),
|
||||||
|
boot_inherited=False,
|
||||||
|
path_exists=True,
|
||||||
|
role_kind="reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||||
|
)
|
||||||
|
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
|
||||||
|
|
||||||
|
def test_corroborated_bindings_are_untouched(self):
|
||||||
|
for kwargs in (
|
||||||
|
dict(role_env_value=os.getcwd()),
|
||||||
|
dict(session_lease_worktree=os.getcwd()),
|
||||||
|
):
|
||||||
|
classification = sbr.classify_active_worktree_binding(
|
||||||
|
active_value=os.getcwd(),
|
||||||
|
boot_inherited=True,
|
||||||
|
path_exists=True,
|
||||||
|
role_kind="reviewer",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
|
||||||
|
)
|
||||||
|
self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"])
|
||||||
|
|
||||||
|
def test_author_inherited_binding_stays_corroborated(self):
|
||||||
|
classification = sbr.classify_active_worktree_binding(
|
||||||
|
active_value=os.getcwd(),
|
||||||
|
boot_inherited=True,
|
||||||
|
path_exists=True,
|
||||||
|
role_kind="author",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classification["classification"], sbr.CLASSIFICATION_CORROBORATED
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apply_recovery_clears_env_only_for_authorized_plan(self):
|
||||||
|
env = {"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"}
|
||||||
|
plan = sbr.plan_recovery(
|
||||||
|
sbr.classify_active_worktree_binding(
|
||||||
|
active_value=env["GITEA_ACTIVE_WORKTREE"],
|
||||||
|
boot_inherited=True,
|
||||||
|
path_exists=False,
|
||||||
|
role_kind="reviewer",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
applied = sbr.apply_recovery(plan, env=env)
|
||||||
|
self.assertTrue(applied["performed"])
|
||||||
|
self.assertNotIn("GITEA_ACTIVE_WORKTREE", env)
|
||||||
|
self.assertIn("review-pr-654", applied["cleared_value"])
|
||||||
|
|
||||||
|
def test_apply_recovery_refuses_unauthorized_plan(self):
|
||||||
|
env = {"GITEA_ACTIVE_WORKTREE": os.getcwd()}
|
||||||
|
plan = sbr.plan_recovery(
|
||||||
|
sbr.classify_active_worktree_binding(
|
||||||
|
active_value=env["GITEA_ACTIVE_WORKTREE"],
|
||||||
|
boot_inherited=True,
|
||||||
|
path_exists=True,
|
||||||
|
role_kind="reviewer",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
applied = sbr.apply_recovery(plan, env=env)
|
||||||
|
self.assertFalse(applied["performed"])
|
||||||
|
self.assertIn("GITEA_ACTIVE_WORKTREE", env)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeExpiryDiagnosis(unittest.TestCase):
|
||||||
|
"""Scenario 5: canonical expiry flips wait into acquire/guarded cleanup."""
|
||||||
|
|
||||||
|
def _diagnose(self, comments, **kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
pr_number=PR,
|
||||||
|
current_session_id=None,
|
||||||
|
current_reviewer_identity="fresh-reviewer",
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
formal_reviews=[],
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return leases.diagnose_reviewer_pr_lease_handoff(comments, **defaults)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
leases._SESSION_LEASE = None
|
||||||
|
|
||||||
|
def test_pre_expiry_superseded_no_terminal_stays_wait(self):
|
||||||
|
diagnosis = self._diagnose(_fresh_superseded_comments())
|
||||||
|
self.assertEqual(
|
||||||
|
diagnosis["classification"], "ambiguous_conflicting_evidence"
|
||||||
|
)
|
||||||
|
self.assertEqual(diagnosis["next_action"], "wait")
|
||||||
|
|
||||||
|
def test_post_expiry_without_orphan_evidence_unlocks_acquire(self):
|
||||||
|
diagnosis = self._diagnose(_expired_superseded_comments())
|
||||||
|
self.assertEqual(
|
||||||
|
diagnosis["classification"], "orphaned_expired_superseded_head"
|
||||||
|
)
|
||||||
|
self.assertEqual(diagnosis["next_action"], "acquire")
|
||||||
|
|
||||||
|
def test_post_expiry_with_orphan_evidence_offers_guarded_cleanup(self):
|
||||||
|
diagnosis = self._diagnose(
|
||||||
|
_expired_superseded_comments(),
|
||||||
|
owner_process_alive=False,
|
||||||
|
worktree_clean=True,
|
||||||
|
worktree_exists=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
diagnosis["classification"], "orphaned_expired_superseded_head"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
diagnosis["next_action"], "cleanup_obsolete_reviewer_comment_lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expired_lease_no_longer_blocks_fresh_acquisition(self):
|
||||||
|
assessment = leases.assess_acquire_lease(
|
||||||
|
_expired_superseded_comments(),
|
||||||
|
pr_number=PR,
|
||||||
|
reviewer_identity="fresh-reviewer",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="99999-fresh",
|
||||||
|
repo=REPO,
|
||||||
|
issue_number=ISSUE,
|
||||||
|
worktree="branches/review-pr-701-fresh",
|
||||||
|
candidate_head=NEW_HEAD,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="2" * 40,
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment.get("acquire_allowed"))
|
||||||
|
|
||||||
|
def test_unparseable_expiry_fails_closed_in_cleanup(self):
|
||||||
|
comment = _lease_comment(
|
||||||
|
last_activity=_now() - timedelta(hours=3), expires_at=None
|
||||||
|
)
|
||||||
|
comment["body"] = comment["body"].replace(
|
||||||
|
"expires_at: ", "expires_at: not-a-timestamp"
|
||||||
|
)
|
||||||
|
result = leases.assess_obsolete_reviewer_comment_lease_cleanup(
|
||||||
|
[comment],
|
||||||
|
pr_number=PR,
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
formal_reviews=[],
|
||||||
|
repo=REPO,
|
||||||
|
expected_repo=REPO,
|
||||||
|
requesting_session_id="fresh-controller",
|
||||||
|
controller_recovery_authorized=True,
|
||||||
|
worktree_exists=True,
|
||||||
|
worktree_clean=True,
|
||||||
|
owner_process_alive=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["cleanup_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""#714: production first-bind pins the workspace-verified repository scope.
|
||||||
|
|
||||||
|
These tests drive the real entry points (``gitea_whoami``,
|
||||||
|
``gitea_get_runtime_context``, ``gitea_resolve_task_capability``,
|
||||||
|
``gitea_activate_profile``) in production order. They deliberately do not
|
||||||
|
construct a ``_SessionContext`` directly: the defect they cover is that the
|
||||||
|
production first-bind path left ``repository``/``org`` unbound, so
|
||||||
|
``assess_session_context`` skipped its repository/org drift checks and a
|
||||||
|
same-host mutation against another repository was not blocked.
|
||||||
|
|
||||||
|
The trusted repository identity comes from the workspace-aligned git remote
|
||||||
|
(``Scaled-Tech-Consulting/Gitea-Tools`` for this checkout), never from
|
||||||
|
``REMOTES`` (whose ``prgs`` default repo is ``Timesheet``) and never from a
|
||||||
|
caller-supplied ``org``/``repo`` argument.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
WORKSPACE_ORG = "Scaled-Tech-Consulting"
|
||||||
|
WORKSPACE_REPO = "Gitea-Tools"
|
||||||
|
WORKSPACE_SLUG = f"{WORKSPACE_ORG}/{WORKSPACE_REPO}"
|
||||||
|
WORKSPACE_URL = f"https://gitea.prgs.cc/{WORKSPACE_SLUG}.git"
|
||||||
|
|
||||||
|
_BASE_AUTHOR_OPS = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _config(allowed_repositories=None):
|
||||||
|
profile = {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||||
|
"allowed_operations": list(_BASE_AUTHOR_OPS),
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
}
|
||||||
|
if allowed_repositories is not None:
|
||||||
|
profile["allowed_repositories"] = list(allowed_repositories)
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
# v2-contexts normalizes the config and keeps only rules.* — a
|
||||||
|
# top-level allow_runtime_switching would be dropped.
|
||||||
|
"rules": {"allow_runtime_switching": True},
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": {"prgs-author": profile},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _ProductionOrderBase(unittest.TestCase):
|
||||||
|
"""Real entry points, pinned workspace remote, mocked network only."""
|
||||||
|
|
||||||
|
allowed_repositories = [WORKSPACE_SLUG]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(_config(self.allowed_repositories)))
|
||||||
|
self._env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||||
|
}
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
# Pin the workspace git remote so the trusted source is deterministic
|
||||||
|
# and never depends on the developer's checkout layout.
|
||||||
|
self._remote_url = patch.object(
|
||||||
|
srv, "_local_git_remote_url", side_effect=self._local_remote_url
|
||||||
|
)
|
||||||
|
self._remote_url.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._remote_url.stop()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
srv._MUTATION_AUTHORITY = None
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _local_remote_url(self, remote_name):
|
||||||
|
return WORKSPACE_URL if remote_name == "prgs" else None
|
||||||
|
|
||||||
|
def _api(self, method, url, header):
|
||||||
|
return {
|
||||||
|
"login": "jcwalker3",
|
||||||
|
"full_name": "Test",
|
||||||
|
"id": 1,
|
||||||
|
"email": "[email protected]",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _live(self):
|
||||||
|
return patch("gitea_mcp_server.api_request", side_effect=self._api)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionFirstBindPinsRepository(_ProductionOrderBase):
|
||||||
|
def test_whoami_first_bind_is_complete(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(ctx)
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
self.assertEqual(ctx["remote"], "prgs")
|
||||||
|
self.assertEqual(ctx["host"], "gitea.prgs.cc")
|
||||||
|
|
||||||
|
def test_runtime_context_first_bind_is_complete(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_get_runtime_context(remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
def test_capability_preflight_first_bind_is_complete(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
def test_activate_profile_binds_complete_context(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionOrderRepositoryDriftBlocks(_ProductionOrderBase):
|
||||||
|
def test_whoami_then_same_host_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Other-Tools" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_whoami_then_timesheet_blocks(self):
|
||||||
|
"""REMOTES['prgs'].repo is Timesheet — a default target, not a scope."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs",
|
||||||
|
org=WORKSPACE_ORG,
|
||||||
|
repo="Timesheet",
|
||||||
|
org_explicit=True,
|
||||||
|
repo_explicit=True,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Timesheet" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_whoami_then_same_host_other_org_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Other-Org", repo=WORKSPACE_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("Other-Org" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_context_then_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_get_runtime_context(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_capability_preflight_then_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_resolve_task_capability(task="comment_issue", remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_activate_profile_then_other_repository_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_cross_host_still_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(remote="dadeschools")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("cross-host" in r for r in blocked["reasons"]), blocked["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authorized_repository_mutation_remains_allowed(self):
|
||||||
|
"""Normal PR #715 author comment/push path must stay functional."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
self.assertIsNone(
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# A bare call with no override resolves to the same bound scope.
|
||||||
|
self.assertIsNone(srv._session_context_mutation_block(remote="prgs"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestMutationRequestCannotEstablishBinding(_ProductionOrderBase):
|
||||||
|
def test_caller_values_cannot_establish_binding_on_first_mutation(self):
|
||||||
|
"""A mutation-first session must not be pinned by request values."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||||
|
)
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(ctx)
|
||||||
|
# Bound to the verified workspace, never to the request values.
|
||||||
|
self.assertEqual(ctx["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(ctx["repository"], WORKSPACE_REPO)
|
||||||
|
|
||||||
|
def test_mutation_first_with_attacker_values_is_blocked(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Attacker-Org", repo="Attacker-Repo"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
def test_later_call_cannot_overwrite_bound_repository(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
before = session_ctx.get_session_context()
|
||||||
|
for _ in range(3):
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org="Other-Org", repo="Other-Tools"
|
||||||
|
)
|
||||||
|
self.assertEqual(session_ctx.get_session_context(), before)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnverifiedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||||
|
def _local_remote_url(self, remote_name):
|
||||||
|
return None # no verifiable workspace repository
|
||||||
|
|
||||||
|
def test_mutation_blocks_when_workspace_repository_unverified(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
ctx = session_ctx.get_session_context()
|
||||||
|
self.assertIsNone(ctx["repository"])
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=WORKSPACE_ORG, repo=WORKSPACE_REPO
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"no verified workspace repository" in r or "unverified" in r
|
||||||
|
for r in blocked["reasons"]
|
||||||
|
),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_activate_profile_rejects_unverifiable_workspace(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
self.assertFalse(res.get("success", True))
|
||||||
|
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnauthorizedWorkspaceFailsClosed(_ProductionOrderBase):
|
||||||
|
allowed_repositories = ["Scaled-Tech-Consulting/Some-Other-Project"]
|
||||||
|
|
||||||
|
def test_workspace_absent_from_allowlist_blocks_mutation(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("not authorized" in r for r in blocked["reasons"]),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workspace_absent_from_allowlist_blocks_activation(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
res = srv.gitea_activate_profile(profile_name="prgs-author", remote="prgs")
|
||||||
|
self.assertFalse(res.get("success", True))
|
||||||
|
self.assertEqual(res.get("blocker_kind"), "repository_scope")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimesheetNotAuthorizedInThisPhase(_ProductionOrderBase):
|
||||||
|
"""Cross-project use is paused: only Gitea-Tools is authorized."""
|
||||||
|
|
||||||
|
def test_timesheet_workspace_is_rejected(self):
|
||||||
|
def timesheet_remote(remote_name):
|
||||||
|
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Timesheet.git"
|
||||||
|
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
with patch.object(
|
||||||
|
srv, "_local_git_remote_url", side_effect=timesheet_remote
|
||||||
|
):
|
||||||
|
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("not authorized" in r for r in blocked["reasons"]),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConcurrentFirstBindSelectsOneRepository(_ProductionOrderBase):
|
||||||
|
def test_concurrent_initialization_cannot_change_selected_repository(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
thread_count = 8
|
||||||
|
barrier = threading.Barrier(thread_count)
|
||||||
|
results = []
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def racer(index: int) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
srv._session_context_mutation_block(
|
||||||
|
remote="prgs", org=f"Racer-Org-{index}", repo=f"Racer-Repo-{index}"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
results.append(session_ctx.get_session_context())
|
||||||
|
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=racer, args=(i,)) for i in range(thread_count)
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
self.assertFalse(any(t.is_alive() for t in threads))
|
||||||
|
winner = session_ctx.get_session_context()
|
||||||
|
self.assertEqual(winner["org"], WORKSPACE_ORG)
|
||||||
|
self.assertEqual(winner["repository"], WORKSPACE_REPO)
|
||||||
|
self.assertEqual(results, [winner] * thread_count)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryScopeUnits(unittest.TestCase):
|
||||||
|
def test_absent_scope_is_not_enforced_for_read_diagnostics(self):
|
||||||
|
scope = session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=WORKSPACE_SLUG, allowed=[], profile_name="legacy"
|
||||||
|
)
|
||||||
|
self.assertTrue(scope["proven"])
|
||||||
|
self.assertFalse(scope["scope_enforced"])
|
||||||
|
|
||||||
|
def test_require_scope_blocks_empty_or_missing_allowlist(self):
|
||||||
|
scope = session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=WORKSPACE_SLUG,
|
||||||
|
allowed=[],
|
||||||
|
profile_name="legacy",
|
||||||
|
require_scope=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(scope["block"])
|
||||||
|
self.assertTrue(any("allowed_repositories" in r for r in scope["reasons"]))
|
||||||
|
|
||||||
|
def test_declared_scope_authorizes_only_listed_repository(self):
|
||||||
|
allowed = session_ctx.declared_allowed_repositories(
|
||||||
|
{"allowed_repositories": [WORKSPACE_SLUG]}
|
||||||
|
)
|
||||||
|
self.assertEqual(allowed, [WORKSPACE_SLUG])
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=WORKSPACE_SLUG, allowed=allowed
|
||||||
|
)["proven"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug="Scaled-Tech-Consulting/Timesheet", allowed=allowed
|
||||||
|
)["block"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_workspace_slug_blocks_when_scope_declared(self):
|
||||||
|
scope = session_ctx.assess_repository_scope(
|
||||||
|
workspace_slug=None, allowed=[WORKSPACE_SLUG]
|
||||||
|
)
|
||||||
|
self.assertTrue(scope["block"])
|
||||||
|
|
||||||
|
def test_override_must_match_binding(self):
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_override(
|
||||||
|
requested_org=WORKSPACE_ORG,
|
||||||
|
requested_repo="Other",
|
||||||
|
bound_org=WORKSPACE_ORG,
|
||||||
|
bound_repo=WORKSPACE_REPO,
|
||||||
|
)["block"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_override(
|
||||||
|
requested_org="Other-Org",
|
||||||
|
requested_repo=WORKSPACE_REPO,
|
||||||
|
bound_org=WORKSPACE_ORG,
|
||||||
|
bound_repo=WORKSPACE_REPO,
|
||||||
|
)["block"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.assess_repository_override(
|
||||||
|
requested_org=WORKSPACE_ORG,
|
||||||
|
requested_repo=WORKSPACE_REPO,
|
||||||
|
bound_org=WORKSPACE_ORG,
|
||||||
|
bound_repo=WORKSPACE_REPO,
|
||||||
|
)["proven"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_allowlist_entries_are_ignored_in_non_strict_mode(self):
|
||||||
|
self.assertEqual(
|
||||||
|
session_ctx.declared_allowed_repositories(
|
||||||
|
{"allowed_repositories": ["not-a-slug", 17, None, WORKSPACE_SLUG]}
|
||||||
|
),
|
||||||
|
[WORKSPACE_SLUG],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_strict_mode_rejects_malformed_allowlist_entries(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
session_ctx.declared_allowed_repositories(
|
||||||
|
{"allowed_repositories": ["not-a-slug", WORKSPACE_SLUG]},
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemotesDefaultNotCallerOverride(_ProductionOrderBase):
|
||||||
|
def test_resolved_timesheet_default_does_not_block_when_bound_to_workspace(self):
|
||||||
|
"""Tools historically pass REMOTES-filled Timesheet after _resolve; that
|
||||||
|
must not be treated as an explicit override against Gitea-Tools."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
# Simulate create_issue after resolve: org/repo are REMOTES defaults
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Timesheet",
|
||||||
|
)
|
||||||
|
self.assertIsNone(blocked, getattr(blocked, "get", lambda *_: blocked)("reasons") if blocked else None)
|
||||||
|
|
||||||
|
def test_git_unavailable_blocks_mutation(self):
|
||||||
|
def no_remote(_name):
|
||||||
|
return None
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
with patch.object(srv, "_local_git_remote_url", side_effect=no_remote):
|
||||||
|
blocked = srv._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"workspace" in r.lower() or "allowed_repositories" in r or "unverified" in r
|
||||||
|
for r in blocked["reasons"]
|
||||||
|
),
|
||||||
|
blocked["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_mismatch_still_blocks(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False), self._live():
|
||||||
|
srv.gitea_whoami(remote="prgs")
|
||||||
|
blocked = srv._session_context_mutation_block(
|
||||||
|
remote="prgs",
|
||||||
|
org=WORKSPACE_ORG,
|
||||||
|
repo="Other-Tools",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
"""#714: fail closed on cross-host MCP profile drift and capability substitution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
import session_context_binding as session_ctx # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_714 = {
|
||||||
|
"version": 2,
|
||||||
|
"allow_runtime_switching": True,
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.dadeschools.net"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_PRGS_AUTHOR"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
},
|
||||||
|
"mdcps-reviewer": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "mdcps",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "913443",
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_REVIEWER"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
"allowed_repositories": ["913443/eAgenda"],
|
||||||
|
"execution_profile": "mdcps-reviewer",
|
||||||
|
},
|
||||||
|
"mdcps-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "mdcps",
|
||||||
|
"role": "author",
|
||||||
|
"username": "913443",
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MDCPS_AUTHOR"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
"allowed_repositories": ["913443/eAgenda"],
|
||||||
|
"execution_profile": "mdcps-author",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue714SessionContextImmutability(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(CONFIG_714))
|
||||||
|
self._remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{
|
||||||
|
"dadeschools": {
|
||||||
|
"host": "gitea.dadeschools.net",
|
||||||
|
"org": "913443",
|
||||||
|
"repo": "eAgenda",
|
||||||
|
},
|
||||||
|
"prgs": {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._remotes.start()
|
||||||
|
def _url(name):
|
||||||
|
if name == "dadeschools":
|
||||||
|
return "https://gitea.dadeschools.net/913443/eAgenda.git"
|
||||||
|
if name == "prgs":
|
||||||
|
return "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
|
return None
|
||||||
|
self._url_patch = patch.object(mcp_server, "_local_git_remote_url", side_effect=_url)
|
||||||
|
self._url_patch.start()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._MUTATION_AUTHORITY = None
|
||||||
|
self._env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "mdcps-reviewer",
|
||||||
|
"GITEA_TOKEN_MDCPS_REVIEWER": "mdcps-reviewer-token",
|
||||||
|
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._url_patch.stop()
|
||||||
|
self._remotes.stop()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
mcp_server._MUTATION_AUTHORITY = None
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _api_side_effect(self, method, url, header):
|
||||||
|
# Token identity mapping for whoami endpoint
|
||||||
|
auth = str(header)
|
||||||
|
if "mdcps-reviewer-token" in auth or "mdcps-author-token" in auth:
|
||||||
|
login = "913443"
|
||||||
|
else:
|
||||||
|
login = "jcwalker3"
|
||||||
|
return {"login": login, "full_name": "Test", "id": 1, "email": "[email protected]"}
|
||||||
|
|
||||||
|
def test_pin_mdcps_reviewer_never_drifts_to_prgs_author(self):
|
||||||
|
"""Reproduce the incident: pin mdcps-reviewer then resolve comment_issue."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
act = mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertTrue(act["success"])
|
||||||
|
self.assertEqual(act["after_profile"], "mdcps-reviewer")
|
||||||
|
|
||||||
|
who1 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||||
|
self.assertEqual(who1["profile"]["profile_name"], "mdcps-reviewer")
|
||||||
|
self.assertEqual(who1["username"], "913443")
|
||||||
|
|
||||||
|
# Capability that mdcps-reviewer lacks — must NOT switch to prgs-author
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertFalse(res.get("auto_profile_substitution", True))
|
||||||
|
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||||
|
self.assertIn("mdcps-author", res["matching_configured_profile"])
|
||||||
|
|
||||||
|
who2 = mcp_server.gitea_whoami(remote="dadeschools")
|
||||||
|
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||||
|
self.assertEqual(who2["profile"]["profile_name"], "mdcps-reviewer")
|
||||||
|
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||||
|
# Still pinned — never prgs-author
|
||||||
|
self.assertEqual(
|
||||||
|
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repeated_calls_remain_on_mdcps_reviewer(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
for _ in range(5):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="review_pr", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-reviewer")
|
||||||
|
who = mcp_server.gitea_whoami(remote="dadeschools")
|
||||||
|
self.assertEqual(who["profile"]["profile_name"], "mdcps-reviewer")
|
||||||
|
rt = mcp_server.gitea_get_runtime_context(remote="dadeschools")
|
||||||
|
self.assertEqual(rt["active_profile"], "mdcps-reviewer")
|
||||||
|
|
||||||
|
def test_dadeschools_request_cannot_resolve_through_prgs_author(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||||
|
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||||
|
# Gate must not silently switch either
|
||||||
|
blocked = mcp_server._profile_permission_block(
|
||||||
|
"gitea.issue.comment", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertFalse(blocked.get("success", True))
|
||||||
|
self.assertEqual(
|
||||||
|
gitea_config.selected_profile_name(), "mdcps-reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prgs_request_cannot_resolve_through_mdcps_profile(self):
|
||||||
|
"""Reverse of the incident: a pinned MDCPS profile must never serve a
|
||||||
|
prgs request, nor be silently swapped for the prgs-side profile."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-author", remote="dadeschools"
|
||||||
|
)
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(res["active_profile"], "prgs-author")
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertTrue(res["stop_required"])
|
||||||
|
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(any("cross-host" in r for r in blocked["reasons"]))
|
||||||
|
self.assertEqual(gitea_config.selected_profile_name(), "mdcps-author")
|
||||||
|
|
||||||
|
def test_unsupported_capability_fails_closed_structured(self):
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
|
self.assertTrue(res["stop_required"])
|
||||||
|
self.assertIn("reason", res)
|
||||||
|
self.assertIn("exact_safe_next_action", res)
|
||||||
|
self.assertIn("activate_profile", res["exact_safe_next_action"])
|
||||||
|
# Unknown task still fail closed (structured denial after #723;
|
||||||
|
# never raises into internal_error and never substitutes profile).
|
||||||
|
unknown = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="reopen_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertFalse(unknown.get("allowed_in_current_session"))
|
||||||
|
self.assertTrue(unknown.get("stop_required"))
|
||||||
|
self.assertEqual(unknown.get("reason_code"), "unknown_task")
|
||||||
|
self.assertEqual(unknown.get("mutation_performed"), False)
|
||||||
|
self.assertEqual(unknown.get("active_profile"), "mdcps-reviewer")
|
||||||
|
self.assertNotEqual(unknown.get("active_profile"), "prgs-author")
|
||||||
|
|
||||||
|
def test_identity_mismatch_blocks_mutation(self):
|
||||||
|
"""Profile expects 913443 but authenticated as jcwalker3."""
|
||||||
|
|
||||||
|
def wrong_identity(method, url, header):
|
||||||
|
return {
|
||||||
|
"login": "jcwalker3",
|
||||||
|
"full_name": "Wrong",
|
||||||
|
"id": 9,
|
||||||
|
"email": "[email protected]",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=wrong_identity):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
blocked = mcp_server._session_context_mutation_block(
|
||||||
|
remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("identity mismatch" in r for r in blocked["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_host_mismatch_blocks_mutation(self):
|
||||||
|
"""mdcps-reviewer cannot serve prgs remote."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
blocked = mcp_server._session_context_mutation_block(remote="prgs")
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any("cross-host" in r for r in blocked["reasons"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_drift_cannot_reach_write(self):
|
||||||
|
"""Simulated mid-session profile override cannot pass permission gate."""
|
||||||
|
with patch.dict(os.environ, self._env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="mdcps-reviewer", remote="dadeschools"
|
||||||
|
)
|
||||||
|
# Bind session as mdcps-reviewer
|
||||||
|
self.assertEqual(
|
||||||
|
session_ctx.get_session_context()["profile_name"],
|
||||||
|
"mdcps-reviewer",
|
||||||
|
)
|
||||||
|
# Hostile override without activate_profile
|
||||||
|
gitea_config._active_profile_override = "prgs-author"
|
||||||
|
blocked = mcp_server._session_context_mutation_block(
|
||||||
|
remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertTrue(any("drift" in r or "cross-host" in r for r in blocked["reasons"]))
|
||||||
|
|
||||||
|
def test_static_prgs_author_still_works(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
"GITEA_TOKEN_PRGS_AUTHOR": "prgs-author-token",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "prgs-author")
|
||||||
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
self.assertEqual(res["active_identity"], "jcwalker3")
|
||||||
|
|
||||||
|
def test_static_mdcps_author_still_works(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": "mdcps-author",
|
||||||
|
"GITEA_TOKEN_MDCPS_AUTHOR": "mdcps-author-token",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
with patch("mcp_server.api_request", side_effect=self._api_side_effect):
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="comment_issue", remote="dadeschools"
|
||||||
|
)
|
||||||
|
self.assertEqual(res["active_profile"], "mdcps-author")
|
||||||
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
self.assertNotIn("prgs-author", res["matching_configured_profile"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionContextBindingUnit(unittest.TestCase):
|
||||||
|
def test_profile_matches_remote_by_base_url(self):
|
||||||
|
remotes = {
|
||||||
|
"dadeschools": {"host": "gitea.dadeschools.net"},
|
||||||
|
"prgs": {"host": "gitea.prgs.cc"},
|
||||||
|
}
|
||||||
|
mdcps = {"base_url": "https://gitea.dadeschools.net", "context": "mdcps"}
|
||||||
|
prgs = {"base_url": "https://gitea.prgs.cc", "context": "prgs"}
|
||||||
|
self.assertTrue(
|
||||||
|
session_ctx.profile_matches_remote(mdcps, "dadeschools", remotes)
|
||||||
|
)
|
||||||
|
self.assertFalse(session_ctx.profile_matches_remote(mdcps, "prgs", remotes))
|
||||||
|
self.assertTrue(session_ctx.profile_matches_remote(prgs, "prgs", remotes))
|
||||||
|
|
||||||
|
def test_bind_and_detect_drift(self):
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
ok = session_ctx.assess_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
)
|
||||||
|
self.assertTrue(ok["proven"])
|
||||||
|
bad = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="jcwalker3",
|
||||||
|
)
|
||||||
|
self.assertTrue(bad["block"])
|
||||||
|
self.assertTrue(any("drift" in r for r in bad["reasons"]))
|
||||||
|
|
||||||
|
def test_bound_context_survives_multiple_calls_and_exceptions(self):
|
||||||
|
original = session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(3):
|
||||||
|
observed = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
source="interleaved-test-call",
|
||||||
|
)
|
||||||
|
self.assertEqual(observed, original)
|
||||||
|
raise RuntimeError("simulated caller failure")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertEqual(session_ctx.get_session_context(), original)
|
||||||
|
drift = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(drift["block"])
|
||||||
|
|
||||||
|
def test_parallel_calls_cannot_overwrite_established_binding(self):
|
||||||
|
original = session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
barrier = threading.Barrier(9)
|
||||||
|
results = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
|
||||||
|
def competing_seed(index: int) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
result = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name=f"prgs-author-{index}",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity=f"other-{index}",
|
||||||
|
source="parallel-test-call",
|
||||||
|
)
|
||||||
|
with results_lock:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=competing_seed, args=(i,)) for i in range(8)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
barrier.wait()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||||
|
self.assertEqual(results, [original] * 8)
|
||||||
|
self.assertEqual(session_ctx.get_session_context(), original)
|
||||||
|
|
||||||
|
def test_concurrent_initialization_has_single_winning_binding(self):
|
||||||
|
"""Racing first-binds from an unbound context: exactly one winner, and
|
||||||
|
every racer observes that same complete binding (never a partial one)."""
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
thread_count = 8
|
||||||
|
barrier = threading.Barrier(thread_count)
|
||||||
|
results = []
|
||||||
|
results_lock = threading.Lock()
|
||||||
|
|
||||||
|
def racing_seed(index: int) -> None:
|
||||||
|
barrier.wait()
|
||||||
|
result = session_ctx.seed_session_context_if_unbound(
|
||||||
|
profile_name=f"prgs-author-{index}",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity=f"user-{index}",
|
||||||
|
source="concurrent-init-test",
|
||||||
|
)
|
||||||
|
with results_lock:
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=racing_seed, args=(i,))
|
||||||
|
for i in range(thread_count)
|
||||||
|
]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
self.assertFalse(any(thread.is_alive() for thread in threads))
|
||||||
|
winner = session_ctx.get_session_context()
|
||||||
|
self.assertIsNotNone(winner)
|
||||||
|
self.assertEqual(len(results), thread_count)
|
||||||
|
self.assertEqual(results, [winner] * thread_count)
|
||||||
|
# A losing racer's identity must never be spliced into the winner.
|
||||||
|
self.assertEqual(
|
||||||
|
winner["identity"], winner["profile_name"].replace("prgs-author-", "user-")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_mismatch_blocks_before_mutation(self):
|
||||||
|
"""Same host and profile, different repository, must still fail closed."""
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
same = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(same["proven"])
|
||||||
|
other_repo = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="eAgenda",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(other_repo["block"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("repository drift" in r for r in other_repo["reasons"])
|
||||||
|
)
|
||||||
|
other_org = session_ctx.assess_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
repository="Gitea-Tools",
|
||||||
|
org="913443",
|
||||||
|
require_bound=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(other_org["block"])
|
||||||
|
self.assertTrue(any("org drift" in r for r in other_org["reasons"]))
|
||||||
|
|
||||||
|
def test_returned_snapshot_cannot_mutate_binding(self):
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
snapshot = session_ctx.get_session_context()
|
||||||
|
snapshot["profile_name"] = "prgs-author"
|
||||||
|
self.assertEqual(
|
||||||
|
session_ctx.get_session_context()["profile_name"], "mdcps-reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionContextTestBoundaryIsolation(unittest.TestCase):
|
||||||
|
def test_mdcps_binding_starts_clean_and_does_not_escape_test(self):
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="mdcps-reviewer",
|
||||||
|
remote="dadeschools",
|
||||||
|
host="gitea.dadeschools.net",
|
||||||
|
identity="913443",
|
||||||
|
source="test-boundary",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prgs_binding_starts_clean_and_does_not_escape_test(self):
|
||||||
|
self.assertIsNone(session_ctx.get_session_context())
|
||||||
|
session_ctx.bind_session_context(
|
||||||
|
profile_name="prgs-author",
|
||||||
|
remote="prgs",
|
||||||
|
host="gitea.prgs.cc",
|
||||||
|
identity="jcwalker3",
|
||||||
|
source="test-boundary",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reset_helper_is_rejected_outside_pytest_boundary(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
session_ctx._reset_session_context_for_testing()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue714MergeCoexistenceWithMaster(unittest.TestCase):
|
||||||
|
"""Conflict-resolution regressions after merging advanced master into #715.
|
||||||
|
|
||||||
|
Preserves:
|
||||||
|
- #714 immutable session / no auto profile substitution
|
||||||
|
- #685 side-effect-free resolve (auto_recover=False)
|
||||||
|
- #709 actor identity helper from master
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_auto_switch_helpers_remain_fail_closed(self):
|
||||||
|
self.assertFalse(mcp_server._try_auto_switch_for_operation("gitea.pr.review"))
|
||||||
|
self.assertFalse(
|
||||||
|
mcp_server._try_auto_switch_for_operation(
|
||||||
|
"gitea.issue.comment", host="gitea.prgs.cc"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Active profile is prgs-author; cannot match mdcps review permission
|
||||||
|
# and must not switch.
|
||||||
|
with patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.issue.create"],
|
||||||
|
"forbidden_operations": ["gitea.pr.approve"],
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"context": "prgs",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
matched = mcp_server._ensure_matching_profile(
|
||||||
|
"gitea.pr.approve", "reviewer", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIsNone(matched)
|
||||||
|
self.assertNotEqual(
|
||||||
|
gitea_config.selected_profile_name() or "prgs-author",
|
||||||
|
"mdcps-reviewer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authenticated_actor_helper_survives_merge(self):
|
||||||
|
"""Master #709 F7 actor identity coexists with #714 immutability."""
|
||||||
|
self.assertTrue(hasattr(mcp_server, "_authenticated_actor"))
|
||||||
|
with patch(
|
||||||
|
"mcp_server.get_auth_header", return_value={"Authorization": "token x"}
|
||||||
|
), patch(
|
||||||
|
"mcp_server.api_request",
|
||||||
|
return_value={"id": 42, "login": "sysadmin"},
|
||||||
|
):
|
||||||
|
mcp_server._ACTOR_IDENTITY_CACHE.clear()
|
||||||
|
actor = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||||
|
self.assertEqual(actor.get("user_id"), 42)
|
||||||
|
self.assertEqual(actor.get("login"), "sysadmin")
|
||||||
|
# Cached; second call does not re-request
|
||||||
|
with patch("mcp_server.api_request") as mock_api:
|
||||||
|
actor2 = mcp_server._authenticated_actor("gitea.prgs.cc")
|
||||||
|
mock_api.assert_not_called()
|
||||||
|
self.assertEqual(actor2.get("user_id"), 42)
|
||||||
|
|
||||||
|
def test_resolve_still_report_only_after_master_merge(self):
|
||||||
|
"""#685: resolve path must not pass auto_recover=True after conflict merge."""
|
||||||
|
allowed = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"context": "prgs",
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": allowed,
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"context": "prgs",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fake_binding = {
|
||||||
|
"classification": "unbound",
|
||||||
|
"active_worktree": None,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
with patch.dict(
|
||||||
|
os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=profile
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.gitea_config, "load_config", return_value=config
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_authenticated_username", return_value="jcwalker3"
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_assess_stale_active_binding",
|
||||||
|
return_value=fake_binding,
|
||||||
|
) as mock_assess, patch.object(
|
||||||
|
mcp_server, "record_preflight_check", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "record_mutation_authority", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "init_review_decision_lock", return_value=None
|
||||||
|
):
|
||||||
|
result = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="create_issue", remote="prgs"
|
||||||
|
)
|
||||||
|
mock_assess.assert_called()
|
||||||
|
for call in mock_assess.call_args_list:
|
||||||
|
self.assertFalse(call.kwargs.get("auto_recover", True))
|
||||||
|
self.assertEqual(result.get("mutation_performed"), False)
|
||||||
|
# Session context must not have been rewritten by resolve
|
||||||
|
# (report-only; any prior binding stays; unbound stays unbound unless
|
||||||
|
# seed-on-read path is intentional — resolve must not activate peers).
|
||||||
|
self.assertNotEqual(result.get("active_profile"), "mdcps-reviewer")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for the pre-create issue content gate (Issue #582)."""
|
"""Tests for the pre-create issue content gate (Issue #582)."""
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -15,9 +19,10 @@ from issue_content_gate import ( # noqa: E402
|
|||||||
from mcp_server import gitea_create_issue # noqa: E402
|
from mcp_server import gitea_create_issue # noqa: E402
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
"test-author-prgs",
|
||||||
}
|
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeAndPointers(unittest.TestCase):
|
class TestNormalizeAndPointers(unittest.TestCase):
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for pre-create issue duplicate gate (Issue #207)."""
|
"""Tests for pre-create issue duplicate gate (Issue #207)."""
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -19,9 +23,10 @@ from mcp_server import gitea_create_issue # noqa: E402
|
|||||||
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
|
from review_proofs import assess_duplicate_search_proof as proof_assess # noqa: E402
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create",
|
"test-author-prgs",
|
||||||
}
|
GITEA_ALLOWED_OPERATIONS="gitea.issue.create",
|
||||||
|
)
|
||||||
CANONICAL_TITLE = (
|
CANONICAL_TITLE = (
|
||||||
"Add hard queue-target resolution wall before PR inventory "
|
"Add hard queue-target resolution wall before PR inventory "
|
||||||
"or empty-queue claims"
|
"or empty-queue claims"
|
||||||
@@ -162,7 +167,7 @@ class TestCreateIssueMCPGate(unittest.TestCase):
|
|||||||
mock_role_check.return_value = (True, [])
|
mock_role_check.return_value = (True, [])
|
||||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||||
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(__import__("os").environ, ISSUE_WRITE_ENV, clear=True):
|
||||||
result = gitea_create_issue(title="Unique new issue", body="body text")
|
result = gitea_create_issue(title="Unique new issue", body="body text", remote="prgs")
|
||||||
self.assertEqual(result["number"], 1)
|
self.assertEqual(result["number"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for early duplicate-work detection (#400)."""
|
"""Tests for early duplicate-work detection (#400)."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -144,10 +148,12 @@ class TestInjectableDuplicateFetcher(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
):
|
):
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
with patch.dict(os.environ, {
|
env = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
"test-author-prgs",
|
||||||
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
include_example_repo=True,
|
||||||
}, clear=True):
|
GITEA_ISSUE_LOCK_DIR=lock_dir,
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_lock_issue(
|
mcp_server.gitea_lock_issue(
|
||||||
issue_number=400,
|
issue_number=400,
|
||||||
branch_name="feat/issue-400-duplicate-work-preflight",
|
branch_name="feat/issue-400-duplicate-work-preflight",
|
||||||
@@ -161,7 +167,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
self._dir = tempfile.TemporaryDirectory()
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
self._env_patch = patch.dict(
|
self._env_patch = patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
shared_mutation_env(
|
||||||
|
"test-author-prgs",
|
||||||
|
include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self._dir.name,
|
||||||
|
),
|
||||||
clear=False,
|
clear=False,
|
||||||
)
|
)
|
||||||
self._env_patch.start()
|
self._env_patch.start()
|
||||||
@@ -171,6 +181,11 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
self._remotes.start()
|
self._remotes.start()
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
try:
|
||||||
|
import session_context_binding as _sc
|
||||||
|
_sc._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
patch.stopall()
|
patch.stopall()
|
||||||
@@ -205,6 +220,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"audit_label": "test-author",
|
"audit_label": "test-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
})
|
})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||||
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
|
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
|
||||||
@@ -239,6 +256,8 @@ class TestMcpDuplicateRecheck(unittest.TestCase):
|
|||||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"audit_label": "test-author",
|
"audit_label": "test-author",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
})
|
})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||||
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
|
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import install_deterministic_remote_urls # noqa: E402
|
||||||
|
install_deterministic_remote_urls()
|
||||||
"""Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
|
"""Regression tests: issue-write tool gates match gitea_resolve_task_capability (#69)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -41,6 +46,7 @@ CONFIG = {
|
|||||||
"gitea.read", "gitea.issue.create", "gitea.issue.close",
|
"gitea.read", "gitea.issue.create", "gitea.issue.close",
|
||||||
"gitea.issue.comment"],
|
"gitea.issue.comment"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo", "913443/eAgenda"],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
|
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
|
||||||
|
|
||||||
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
|
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Regression tests for gitea_lock_issue MCP tool registration (#521)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -13,22 +17,20 @@ from mcp_server import gitea_create_pr, gitea_lock_issue
|
|||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
|
||||||
ISSUE_WRITE_ENV = {
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"test-author-prgs",
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
CREATE_PR_ENV = {
|
CREATE_PR_ENV = shared_mutation_env(
|
||||||
"GITEA_PROFILE_NAME": "author-test",
|
"test-author-prgs",
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
GITEA_ALLOWED_OPERATIONS=(
|
||||||
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
),
|
),
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
GITEA_FORBIDDEN_OPERATIONS=(
|
||||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
||||||
),
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clean_master_git_state_for_lock():
|
def _clean_master_git_state_for_lock():
|
||||||
|
|||||||
+173
-109
@@ -1,3 +1,11 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import (
|
||||||
|
mutation_profile_env,
|
||||||
|
shared_mutation_env,
|
||||||
|
install_deterministic_remote_urls,
|
||||||
|
) # noqa: E402
|
||||||
"""Tests for the MCP server tool functions.
|
"""Tests for the MCP server tool functions.
|
||||||
|
|
||||||
Each tool is tested by calling the underlying function directly (not through
|
Each tool is tested by calling the underlying function directly (not through
|
||||||
@@ -47,6 +55,7 @@ from gitea_auth import get_profile # noqa: E402
|
|||||||
import gitea_config # noqa: E402
|
import gitea_config # noqa: E402
|
||||||
|
|
||||||
import mcp_server
|
import mcp_server
|
||||||
|
install_deterministic_remote_urls()
|
||||||
import issue_lock_store
|
import issue_lock_store
|
||||||
import gitea_auth
|
import gitea_auth
|
||||||
|
|
||||||
@@ -221,22 +230,26 @@ def _seed_ready_review_decision(
|
|||||||
|
|
||||||
|
|
||||||
# Issue-write tools are profile-gated (#69).
|
# Issue-write tools are profile-gated (#69).
|
||||||
ISSUE_WRITE_ENV = {
|
# Default remote is dadeschools — use the host-aligned config profile so
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
# cross-host session binding does not fail closed (#714).
|
||||||
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
ISSUE_WRITE_ENV = shared_mutation_env(
|
||||||
|
"test-author-dadeschools",
|
||||||
|
GITEA_ALLOWED_OPERATIONS=(
|
||||||
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment,"
|
||||||
|
"gitea.pr.create,gitea.branch.push,gitea.repo.commit,gitea.read"
|
||||||
),
|
),
|
||||||
}
|
)
|
||||||
|
|
||||||
# create_pr is gated on author profile + namespace (#69, #209).
|
# create_pr is gated on author profile + namespace (#69, #209).
|
||||||
CREATE_PR_ENV = {
|
# Default remote is dadeschools; use matching config-backed author profile.
|
||||||
"GITEA_PROFILE_NAME": "author-test",
|
CREATE_PR_ENV = shared_mutation_env(
|
||||||
"GITEA_ALLOWED_OPERATIONS": (
|
"test-author-dadeschools",
|
||||||
"gitea.read,gitea.pr.create,gitea.branch.push"
|
GITEA_ALLOWED_OPERATIONS=(
|
||||||
|
"gitea.read,gitea.pr.create,gitea.branch.push,"
|
||||||
|
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
|
||||||
),
|
),
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": (
|
GITEA_FORBIDDEN_OPERATIONS="gitea.pr.approve,gitea.pr.merge,gitea.pr.review",
|
||||||
"gitea.pr.approve,gitea.pr.merge,gitea.pr.review"
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||||||
|
|
||||||
@@ -287,6 +300,7 @@ def _bind_test_lock(**overrides) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Create Issue
|
# Create Issue
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestCreateIssue(unittest.TestCase):
|
class TestCreateIssue(unittest.TestCase):
|
||||||
|
|
||||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||||
@@ -296,7 +310,7 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_creates_issue(self, _auth, _get_all, mock_api, _role):
|
def test_creates_issue(self, _auth, _get_all, mock_api, _role):
|
||||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_create_issue(title="Test issue", body="body text")
|
result = gitea_create_issue(title="Test issue", body="body text")
|
||||||
self.assertEqual(result["number"], 1)
|
self.assertEqual(result["number"], 1)
|
||||||
self.assertNotIn("url", result)
|
self.assertNotIn("url", result)
|
||||||
@@ -314,7 +328,7 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
def test_create_issue_reveal_opt_in_includes_url(self, _auth, _get_all, mock_api, _role):
|
def test_create_issue_reveal_opt_in_includes_url(self, _auth, _get_all, mock_api, _role):
|
||||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||||
env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
env = {**ISSUE_WRITE_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue(title="Test issue", body="body text")
|
result = gitea_create_issue(title="Test issue", body="body text")
|
||||||
self.assertIn("issues/1", result["url"])
|
self.assertIn("issues/1", result["url"])
|
||||||
|
|
||||||
@@ -325,7 +339,18 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_creates_on_prgs(self, _auth, _get_all, mock_api, _role):
|
def test_creates_on_prgs(self, _auth, _get_all, mock_api, _role):
|
||||||
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
|
mock_api.return_value = {"number": 5, "html_url": "https://gitea.prgs.cc/issues/5"}
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
env = {
|
||||||
|
**ISSUE_WRITE_ENV,
|
||||||
|
"GITEA_MCP_PROFILE": "test-author-prgs",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
import gitea_config
|
||||||
|
gitea_config._active_profile_override = None
|
||||||
|
try:
|
||||||
|
import session_context_binding as _sc
|
||||||
|
_sc._reset_session_context_for_testing()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
result = gitea_create_issue(title="Test", remote="prgs")
|
result = gitea_create_issue(title="Test", remote="prgs")
|
||||||
self.assertEqual(result["number"], 5)
|
self.assertEqual(result["number"], 5)
|
||||||
url = mock_api.call_args[0][1]
|
url = mock_api.call_args[0][1]
|
||||||
@@ -338,7 +363,7 @@ class TestCreateIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_get_all", return_value=[])
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
|
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_create_issue(
|
result = gitea_create_issue(
|
||||||
title="Test issue",
|
title="Test issue",
|
||||||
require_workflow_labels=True,
|
require_workflow_labels=True,
|
||||||
@@ -378,7 +403,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
mock_api.side_effect = api_side_effect
|
mock_api.side_effect = api_side_effect
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||||
result = gitea_create_pr(
|
result = gitea_create_pr(
|
||||||
title="feat: X Closes #123",
|
title="feat: X Closes #123",
|
||||||
@@ -421,7 +446,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
mock_api.side_effect = api_side_effect
|
mock_api.side_effect = api_side_effect
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||||
result = gitea_create_pr(
|
result = gitea_create_pr(
|
||||||
title="feat: X Closes #123",
|
title="feat: X Closes #123",
|
||||||
@@ -447,7 +472,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
|
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||||
result = gitea_create_pr(
|
result = gitea_create_pr(
|
||||||
title="feat: X Closes #123",
|
title="feat: X Closes #123",
|
||||||
@@ -470,7 +495,7 @@ class TestCreatePR(unittest.TestCase):
|
|||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
_bind_test_lock(
|
_bind_test_lock(
|
||||||
issue_number=123,
|
issue_number=123,
|
||||||
branch_name="feat/x",
|
branch_name="feat/x",
|
||||||
@@ -495,7 +520,7 @@ class TestCloseIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_closes_issue(self, _auth, mock_api):
|
def test_closes_issue(self, _auth, mock_api):
|
||||||
mock_api.return_value = {"state": "closed"}
|
mock_api.return_value = {"state": "closed"}
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_close_issue(issue_number=42)
|
result = gitea_close_issue(issue_number=42)
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("42", result["message"])
|
self.assertIn("42", result["message"])
|
||||||
@@ -601,7 +626,7 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
mock_api.side_effect = api_side_effect
|
mock_api.side_effect = api_side_effect
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_mark_issue(issue_number=5, action="start")
|
result = gitea_mark_issue(issue_number=5, action="start")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("claimed", result["message"])
|
self.assertIn("claimed", result["message"])
|
||||||
@@ -616,7 +641,7 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
result = gitea_mark_issue(issue_number=5, action="done")
|
result = gitea_mark_issue(issue_number=5, action="done")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("released", result["message"])
|
self.assertIn("released", result["message"])
|
||||||
@@ -629,7 +654,7 @@ class TestMarkIssue(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_missing_label_raises(self, _auth, mock_api, _labels):
|
def test_missing_label_raises(self, _auth, mock_api, _labels):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
gitea_mark_issue(issue_number=5, action="start")
|
gitea_mark_issue(issue_number=5, action="start")
|
||||||
|
|
||||||
@@ -641,12 +666,12 @@ class TestAuthErrors(unittest.TestCase):
|
|||||||
|
|
||||||
@patch("mcp_server.get_auth_header", return_value=None)
|
@patch("mcp_server.get_auth_header", return_value=None)
|
||||||
def test_no_credentials_raises(self, _auth):
|
def test_no_credentials_raises(self, _auth):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
gitea_create_issue(title="test")
|
gitea_create_issue(title="test")
|
||||||
|
|
||||||
def test_unknown_remote_raises(self):
|
def test_unknown_remote_raises(self):
|
||||||
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
|
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=False):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
gitea_create_issue(title="test", remote="nonexistent")
|
gitea_create_issue(title="test", remote="nonexistent")
|
||||||
|
|
||||||
@@ -860,7 +885,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", do="squash",
|
expected_head_sha="abc123", do="squash",
|
||||||
@@ -893,7 +918,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_changed_files=["b.py", "a.py"],
|
expected_changed_files=["b.py", "a.py"],
|
||||||
@@ -914,7 +939,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -945,7 +970,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -962,7 +987,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api):
|
def test_missing_confirmation_blocks_with_no_api_call(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
|
self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"]))
|
||||||
@@ -973,7 +998,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_wrong_confirmation_blocks(self, _auth, mock_api):
|
def test_wrong_confirmation_blocks(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
|
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
mock_api.assert_not_called()
|
mock_api.assert_not_called()
|
||||||
@@ -985,7 +1010,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
|
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
|
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
gitea_merge_pr(
|
gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs"
|
pr_number=8, confirmation=self._confirm(8), remote="prgs"
|
||||||
@@ -999,7 +1024,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1010,7 +1035,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_unknown_identity_blocks(self, _auth):
|
def test_unknown_identity_blocks(self, _auth):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1022,7 +1047,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_unknown_profile_blocks(self, _auth, mock_api):
|
def test_unknown_profile_blocks(self, _auth, mock_api):
|
||||||
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
||||||
env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops
|
env = {} # no GITEA_ALLOWED_OPERATIONS → empty allowed ops
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1094,7 +1119,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
gitea_merge_pr(
|
gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
@@ -1110,7 +1135,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
{"login": "merger-bot"}, self._pr("author-bot", state="closed")]
|
{"login": "merger-bot"}, self._pr("author-bot", state="closed")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1124,7 +1149,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
{"login": "merger-bot"}, self._pr("author-bot", mergeable=False)]
|
{"login": "merger-bot"}, self._pr("author-bot", mergeable=False)]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1138,7 +1163,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
{"login": "merger-bot"}, self._pr("author-bot", mergeable=None)]
|
{"login": "merger-bot"}, self._pr("author-bot", mergeable=None)]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
pr_number=8, confirmation=self._confirm(8), remote="prgs")
|
||||||
self.assertFalse(r["performed"])
|
self.assertFalse(r["performed"])
|
||||||
@@ -1156,7 +1181,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="deadbeef", remote="prgs")
|
expected_head_sha="deadbeef", remote="prgs")
|
||||||
@@ -1177,7 +1202,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_changed_files=["a.py", "b.py"],
|
expected_changed_files=["a.py", "b.py"],
|
||||||
@@ -1210,7 +1235,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge",
|
"GITEA_ALLOWED_OPERATIONS": "read,merge",
|
||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1229,7 +1254,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1252,7 +1277,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8), remote="prgs",
|
pr_number=8, confirmation=self._confirm(8), remote="prgs",
|
||||||
expected_head_sha=new_sha)
|
expected_head_sha=new_sha)
|
||||||
@@ -1275,7 +1300,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1307,7 +1332,7 @@ class TestMergePR(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_merge_pr(
|
r = gitea_merge_pr(
|
||||||
pr_number=8, confirmation=self._confirm(8),
|
pr_number=8, confirmation=self._confirm(8),
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -1620,10 +1645,8 @@ class TestCommitFiles(unittest.TestCase):
|
|||||||
files = [
|
files = [
|
||||||
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
|
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
|
||||||
]
|
]
|
||||||
env = {
|
env = shared_mutation_env("test-author-dadeschools")
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit",
|
with patch.dict(os.environ, env, clear=False):
|
||||||
}
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
result = gitea_commit_files(
|
result = gitea_commit_files(
|
||||||
files=files,
|
files=files,
|
||||||
message="Initial commit",
|
message="Initial commit",
|
||||||
@@ -1738,7 +1761,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read, review , approve",
|
"GITEA_ALLOWED_OPERATIONS": "read, review , approve",
|
||||||
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
p = get_profile()
|
p = get_profile()
|
||||||
self.assertEqual(p["profile_name"], "gitea-reviewer")
|
self.assertEqual(p["profile_name"], "gitea-reviewer")
|
||||||
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
|
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
|
||||||
@@ -1749,7 +1772,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_PROFILE_NAME": "gitea-author",
|
"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_TOKEN": "super-secret-token",
|
"GITEA_TOKEN": "super-secret-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
p = get_profile()
|
p = get_profile()
|
||||||
blob = repr(p).lower()
|
blob = repr(p).lower()
|
||||||
# The token VALUE must never appear. (The field name
|
# The token VALUE must never appear. (The field name
|
||||||
@@ -1766,7 +1789,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||||
"GITEA_TOKEN": "super-secret-token",
|
"GITEA_TOKEN": "super-secret-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_whoami(remote="prgs")
|
result = gitea_whoami(remote="prgs")
|
||||||
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
|
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -1787,7 +1810,7 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
||||||
"GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token",
|
"GITEA_TOKEN_SOURCE": "keychain:prgs-reviewer-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_whoami(remote="prgs")
|
result = gitea_whoami(remote="prgs")
|
||||||
profile = result["profile"]
|
profile = result["profile"]
|
||||||
self.assertEqual(profile["environment"], None)
|
self.assertEqual(profile["environment"], None)
|
||||||
@@ -1856,7 +1879,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
|||||||
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
"GITEA_AUDIT_LABEL": "reviewer-runtime",
|
||||||
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
p = get_profile()
|
p = get_profile()
|
||||||
self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"])
|
self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"])
|
||||||
self.assertEqual(p["audit_label"], "reviewer-runtime")
|
self.assertEqual(p["audit_label"], "reviewer-runtime")
|
||||||
@@ -1872,7 +1895,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
|||||||
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
"GITEA_TOKEN_SOURCE": "GITEA_TOKEN",
|
||||||
"GITEA_TOKEN": "super-secret-token",
|
"GITEA_TOKEN": "super-secret-token",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs")
|
result = gitea_get_profile(remote="prgs")
|
||||||
self.assertEqual(result["profile_name"], "gitea-reviewer")
|
self.assertEqual(result["profile_name"], "gitea-reviewer")
|
||||||
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
|
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
|
||||||
@@ -1893,7 +1916,7 @@ class TestProfileDiscovery(unittest.TestCase):
|
|||||||
def test_discovery_never_exposes_secrets(self, _auth, mock_api):
|
def test_discovery_never_exposes_secrets(self, _auth, mock_api):
|
||||||
mock_api.return_value = {"id": 3, "login": "reviewer-bot"}
|
mock_api.return_value = {"id": 3, "login": "reviewer-bot"}
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"}
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs")
|
result = gitea_get_profile(remote="prgs")
|
||||||
blob = repr(result).lower()
|
blob = repr(result).lower()
|
||||||
for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()):
|
for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()):
|
||||||
@@ -1975,7 +1998,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
||||||
self.assertTrue(r["eligible"])
|
self.assertTrue(r["eligible"])
|
||||||
self.assertEqual(r["authenticated_user"], "reviewer-bot")
|
self.assertEqual(r["authenticated_user"], "reviewer-bot")
|
||||||
@@ -1988,7 +2011,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||||
@@ -1999,7 +2022,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||||
@@ -2014,7 +2037,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIsNone(r["mergeable"])
|
self.assertIsNone(r["mergeable"])
|
||||||
@@ -2026,7 +2049,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")]
|
mock_api.side_effect = [{"login": "author-bot"}, self._pr("someone-else")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("profile is not allowed to merge", r["reasons"])
|
self.assertIn("profile is not allowed to merge", r["reasons"])
|
||||||
@@ -2038,7 +2061,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": "merge"}
|
"GITEA_FORBIDDEN_OPERATIONS": "merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("profile forbids 'merge'", r["reasons"])
|
self.assertIn("profile forbids 'merge'", r["reasons"])
|
||||||
@@ -2049,7 +2072,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot", state="closed")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="approve", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("PR is not open (state=closed)", r["reasons"])
|
self.assertIn("PR is not open (state=closed)", r["reasons"])
|
||||||
@@ -2058,7 +2081,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
def test_unknown_identity_fails_closed(self, _auth):
|
def test_unknown_identity_fails_closed(self, _auth):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=8, action="merge", remote="prgs")
|
||||||
self.assertFalse(r["eligible"])
|
self.assertFalse(r["eligible"])
|
||||||
self.assertIn("authenticated identity could not be determined", r["reasons"])
|
self.assertIn("authenticated identity could not be determined", r["reasons"])
|
||||||
@@ -2077,7 +2100,7 @@ class TestPrEligibility(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
"GITEA_ALLOWED_OPERATIONS": "read,review",
|
||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
r = gitea_check_pr_eligibility(pr_number=5, action="review", remote="prgs")
|
||||||
blob = repr(r).lower()
|
blob = repr(r).lower()
|
||||||
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
|
for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()):
|
||||||
@@ -2274,7 +2297,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
mock_api.side_effect = [{"login": "jcwalker3"}, self._pr("jcwalker3")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2293,7 +2316,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2323,7 +2346,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2343,7 +2366,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
pr_number=8, action="approve", body="LGTM", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2369,7 +2392,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,request_changes"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="request_changes",
|
pr_number=8, action="request_changes",
|
||||||
body="needs work", remote="prgs",
|
body="needs work", remote="prgs",
|
||||||
@@ -2390,7 +2413,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes
|
"GITEA_ALLOWED_OPERATIONS": "read,review"} # no request_changes
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="request_changes", remote="prgs",
|
pr_number=8, action="request_changes", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2411,7 +2434,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
|
gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123")
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="comment", body="finding", remote="prgs",
|
pr_number=8, action="comment", body="finding", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2429,7 +2452,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
gitea_mark_final_review_decision(
|
gitea_mark_final_review_decision(
|
||||||
8, "comment", remote="prgs", expected_head_sha="abc123",
|
8, "comment", remote="prgs", expected_head_sha="abc123",
|
||||||
)
|
)
|
||||||
@@ -2445,7 +2468,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
def test_unknown_identity_blocks(self, _auth):
|
def test_unknown_identity_blocks(self, _auth):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2460,7 +2483,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
"GITEA_ALLOWED_OPERATIONS": "read,pr.create"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2479,7 +2502,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve",
|
pr_number=8, action="approve",
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -2503,7 +2526,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve",
|
pr_number=8, action="approve",
|
||||||
expected_head_sha="abc123", remote="prgs",
|
expected_head_sha="abc123", remote="prgs",
|
||||||
@@ -2532,7 +2555,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||||
"GITEA_TOKEN": "super-secret-token"}
|
"GITEA_TOKEN": "super-secret-token"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
|
gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123")
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=5, action="approve", remote="prgs",
|
pr_number=5, action="approve", remote="prgs",
|
||||||
@@ -2552,7 +2575,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2567,15 +2590,18 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
lock = _load_review_decision_lock() or {}
|
lock = _load_review_decision_lock() or {}
|
||||||
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
||||||
_save_review_decision_lock(lock)
|
_save_review_decision_lock(lock)
|
||||||
r = gitea_authorize_review_correction(
|
with patch("mcp_server.api_request", return_value={"id": 9001}):
|
||||||
prior_review_id=42,
|
r = gitea_authorize_review_correction(
|
||||||
prior_review_state="approve",
|
prior_review_id=42,
|
||||||
reason="typo",
|
prior_review_state="approve",
|
||||||
operator_authorized=True,
|
reason="typo",
|
||||||
)
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
|
)
|
||||||
self.assertTrue(r["authorized"])
|
self.assertTrue(r["authorized"])
|
||||||
lock = _load_review_decision_lock()
|
lock = _load_review_decision_lock()
|
||||||
self.assertTrue(lock["correction_authorized"])
|
self.assertTrue(lock["correction_authorized"])
|
||||||
|
self.assertEqual(lock["correction_pr_number"], 8)
|
||||||
|
|
||||||
def test_authorize_review_correction_mismatch(self):
|
def test_authorize_review_correction_mismatch(self):
|
||||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||||
@@ -2589,6 +2615,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="approve",
|
prior_review_state="approve",
|
||||||
reason="typo",
|
reason="typo",
|
||||||
operator_authorized=True,
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["authorized"])
|
self.assertFalse(r["authorized"])
|
||||||
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
|
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
|
||||||
@@ -2599,10 +2626,22 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="request_changes",
|
prior_review_state="request_changes",
|
||||||
reason="typo",
|
reason="typo",
|
||||||
operator_authorized=True,
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["authorized"])
|
self.assertFalse(r["authorized"])
|
||||||
self.assertTrue(any("prior review state" in x for x in r["reasons"]))
|
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):
|
def test_authorize_review_correction_requires_operator_auth(self):
|
||||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||||
lock = _load_review_decision_lock() or {}
|
lock = _load_review_decision_lock() or {}
|
||||||
@@ -2615,6 +2654,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="approve",
|
prior_review_state="approve",
|
||||||
reason="typo",
|
reason="typo",
|
||||||
operator_authorized=False,
|
operator_authorized=False,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
self.assertFalse(r["authorized"])
|
self.assertFalse(r["authorized"])
|
||||||
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
|
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
|
||||||
@@ -2629,7 +2669,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
r = gitea_submit_pr_review(
|
r = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2662,7 +2702,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
||||||
with patch("mcp_server.gitea_get_pr_review_feedback",
|
with patch("mcp_server.gitea_get_pr_review_feedback",
|
||||||
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
return_value=dict(_NO_BLOCKER_FEEDBACK)):
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
first = gitea_submit_pr_review(
|
first = gitea_submit_pr_review(
|
||||||
pr_number=8, action="approve", remote="prgs",
|
pr_number=8, action="approve", remote="prgs",
|
||||||
final_review_decision_ready=True,
|
final_review_decision_ready=True,
|
||||||
@@ -2672,6 +2712,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
prior_review_state="approve",
|
prior_review_state="approve",
|
||||||
reason="operator approved correcting mistaken approve",
|
reason="operator approved correcting mistaken approve",
|
||||||
operator_authorized=True,
|
operator_authorized=True,
|
||||||
|
target_pr_number=8,
|
||||||
)
|
)
|
||||||
_mark_request_changes_ready(remote="prgs")
|
_mark_request_changes_ready(remote="prgs")
|
||||||
second = gitea_submit_pr_review(
|
second = gitea_submit_pr_review(
|
||||||
@@ -2687,7 +2728,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
|
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
# Mark decision for specific remote, org, repo
|
# Mark decision for specific remote, org, repo
|
||||||
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
|
gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo")
|
||||||
|
|
||||||
@@ -2725,13 +2766,29 @@ if __name__ == "__main__":
|
|||||||
class TestTrackerHygieneCleanup(unittest.TestCase):
|
class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
self._mut_env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
shared_mutation_env("test-author-dadeschools"),
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self._mut_env.start()
|
||||||
mcp_server.gitea_load_review_workflow()
|
mcp_server.gitea_load_review_workflow()
|
||||||
self.mock_api = patch("mcp_server.api_request").start()
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||||
self.mock_audit = patch("gitea_audit.write_event").start()
|
self.mock_audit = patch("gitea_audit.write_event").start()
|
||||||
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
|
# gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216).
|
||||||
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start()
|
patch("mcp_server.get_profile", return_value={
|
||||||
|
"profile_name": "test-author-dadeschools",
|
||||||
|
"allowed_operations": [
|
||||||
|
"read", "merge", "edit", "close",
|
||||||
|
"gitea.pr.close", "gitea.issue.close", "gitea.read",
|
||||||
|
],
|
||||||
|
"audit_label": "test",
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"allowed_repositories": ["913443/eAgenda"],
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
}).start()
|
||||||
patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
|
patch("mcp_server._list_pr_lease_comments", return_value=[]).start()
|
||||||
patch(
|
patch(
|
||||||
"mcp_server._pr_work_lease_reviewer_block",
|
"mcp_server._pr_work_lease_reviewer_block",
|
||||||
@@ -2739,6 +2796,11 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
|||||||
).start()
|
).start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
try:
|
||||||
|
self._mut_env.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
patch.stopall()
|
patch.stopall()
|
||||||
|
|
||||||
def test_close_issue_removes_in_progress(self):
|
def test_close_issue_removes_in_progress(self):
|
||||||
@@ -3144,7 +3206,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
|||||||
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
||||||
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs",
|
result = gitea_get_profile(remote="prgs",
|
||||||
resolve_identity=False)
|
resolve_identity=False)
|
||||||
blob = repr(result)
|
blob = repr(result)
|
||||||
@@ -3166,7 +3228,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
|||||||
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||||
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
|
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_get_profile(remote="prgs",
|
result = gitea_get_profile(remote="prgs",
|
||||||
resolve_identity=False)
|
resolve_identity=False)
|
||||||
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
||||||
@@ -3178,7 +3240,7 @@ class TestEndpointRedaction(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
env = {"GITEA_MCP_CONFIG": path,
|
env = {"GITEA_MCP_CONFIG": path,
|
||||||
"GITEA_MCP_PROFILE": "prgs-author"}
|
"GITEA_MCP_PROFILE": "prgs-author"}
|
||||||
with patch.dict(os.environ, env, clear=True), \
|
with patch.dict(os.environ, env, clear=False), \
|
||||||
patch("gitea_config._keychain_token", return_value="x"):
|
patch("gitea_config._keychain_token", return_value="x"):
|
||||||
result = gitea_audit_config()
|
result = gitea_audit_config()
|
||||||
finally:
|
finally:
|
||||||
@@ -3266,7 +3328,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
|
def test_list_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||||
mock_api.return_value = [self._comment()]
|
mock_api.return_value = [self._comment()]
|
||||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||||
self.assertIn("issuecomment-101", result["comments"][0]["url"])
|
self.assertIn("issuecomment-101", result["comments"][0]["url"])
|
||||||
|
|
||||||
@@ -3275,7 +3337,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
def test_list_blocked_without_read_permission(self, _auth, mock_api):
|
def test_list_blocked_without_read_permission(self, _auth, mock_api):
|
||||||
env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
|
env = {"GITEA_PROFILE_NAME": "gitea-writer-only",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
|
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
self.assertTrue(result["reasons"])
|
self.assertTrue(result["reasons"])
|
||||||
@@ -3322,7 +3384,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
|
def test_create_reveal_opt_in_includes_url(self, _auth, mock_api):
|
||||||
mock_api.return_value = self._comment(555)
|
mock_api.return_value = self._comment(555)
|
||||||
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
env = dict(self.AUTHOR_ENV, GITEA_MCP_REVEAL_ENDPOINTS="1")
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=9, body="posted", remote="prgs")
|
issue_number=9, body="posted", remote="prgs")
|
||||||
self.assertIn("issuecomment-555", result["url"])
|
self.assertIn("issuecomment-555", result["url"])
|
||||||
@@ -3334,7 +3396,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS":
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
"gitea.read,gitea.pr.review,gitea.pr.comment,"
|
"gitea.read,gitea.pr.review,gitea.pr.comment,"
|
||||||
"gitea.pr.approve,gitea.pr.merge"}
|
"gitea.pr.approve,gitea.pr.merge"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=9, body="posted", remote="prgs")
|
issue_number=9, body="posted", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
@@ -3348,7 +3410,7 @@ class TestIssueCommentTools(unittest.TestCase):
|
|||||||
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
env = {"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||||
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.issue.comment"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=9, body="posted", remote="prgs")
|
issue_number=9, body="posted", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
@@ -3484,7 +3546,7 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
|
|||||||
"GITEA_ALLOWED_OPERATIONS":
|
"GITEA_ALLOWED_OPERATIONS":
|
||||||
"gitea.branch.create,gitea.branch.push,gitea.pr.comment,"
|
"gitea.branch.create,gitea.branch.push,gitea.pr.comment,"
|
||||||
"gitea.pr.create,gitea.read,gitea.repo.commit"}
|
"gitea.pr.create,gitea.read,gitea.repo.commit"}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
result = gitea_create_issue_comment(
|
result = gitea_create_issue_comment(
|
||||||
issue_number=51, body="audit findings", remote="prgs")
|
issue_number=51, body="audit findings", remote="prgs")
|
||||||
self.assertFalse(result["success"])
|
self.assertFalse(result["success"])
|
||||||
@@ -3669,11 +3731,12 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._lock_dir = tempfile.TemporaryDirectory()
|
self._lock_dir = tempfile.TemporaryDirectory()
|
||||||
|
# Most locking tests target remote=prgs; host-align profile (#714).
|
||||||
env = {
|
env = {
|
||||||
**ISSUE_WRITE_ENV,
|
**shared_mutation_env("test-author-prgs"),
|
||||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||||
}
|
}
|
||||||
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
self._env_patcher = patch.dict(os.environ, env, clear=False)
|
||||||
self._env_patcher.start()
|
self._env_patcher.start()
|
||||||
self._dup_fetcher_patcher = patch(
|
self._dup_fetcher_patcher = patch(
|
||||||
"mcp_server.issue_duplicate_context_fetcher",
|
"mcp_server.issue_duplicate_context_fetcher",
|
||||||
@@ -3687,8 +3750,9 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
self._lock_dir.cleanup()
|
self._lock_dir.cleanup()
|
||||||
|
|
||||||
def _create_pr_env(self) -> dict:
|
def _create_pr_env(self) -> dict:
|
||||||
|
# PR-locking tests call create_pr with remote=prgs.
|
||||||
return {
|
return {
|
||||||
**CREATE_PR_ENV,
|
**shared_mutation_env("test-author-prgs"),
|
||||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3707,7 +3771,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
self.assertIn("created_at", res["work_lease"])
|
self.assertIn("created_at", res["work_lease"])
|
||||||
self.assertIn("expires_at", res["work_lease"])
|
self.assertIn("expires_at", res["work_lease"])
|
||||||
self.assertIn("last_heartbeat_at", res["work_lease"])
|
self.assertIn("last_heartbeat_at", res["work_lease"])
|
||||||
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
|
self.assertIn(res["work_lease"]["claimant"]["profile"], ("gitea-default", "test-author-prgs", "prgs-author", "gitea-author"))
|
||||||
self.assertIn("lock_file_path", res)
|
self.assertIn("lock_file_path", res)
|
||||||
lock = issue_lock_store.read_lock_file(res["lock_file_path"])
|
lock = issue_lock_store.read_lock_file(res["lock_file_path"])
|
||||||
self.assertIn("worktree_path", lock)
|
self.assertIn("worktree_path", lock)
|
||||||
@@ -3826,7 +3890,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
@patch("mcp_server.api_get_all", return_value=[])
|
@patch("mcp_server.api_get_all", return_value=[])
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
|
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
|
||||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
||||||
issue_lock_store.save_lock_file(
|
issue_lock_store.save_lock_file(
|
||||||
issue_lock_store.lock_file_path(
|
issue_lock_store.lock_file_path(
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
@@ -3863,7 +3927,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests.
|
Dead-pid / missing-worktree reclaim is covered by issue_lock_store unit tests.
|
||||||
This MCP path must remain fail-closed for sticky expired foreign ownership.
|
This MCP path must remain fail-closed for sticky expired foreign ownership.
|
||||||
"""
|
"""
|
||||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
prgs_repo = "Gitea-Tools" # #714 session-bound repo (not REMOTES Timesheet default)
|
||||||
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
|
# Present worktree + live pid => reclaim_allowed=False even though expires_at is past.
|
||||||
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
|
sticky_worktree = tempfile.mkdtemp(prefix="gitea-sticky-lease-")
|
||||||
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
|
self.addCleanup(lambda: shutil.rmtree(sticky_worktree, ignore_errors=True))
|
||||||
@@ -4057,12 +4121,12 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
worktree = os.path.realpath(os.getcwd())
|
worktree = os.path.realpath(os.getcwd())
|
||||||
with tempfile.TemporaryDirectory() as lock_dir:
|
with tempfile.TemporaryDirectory() as lock_dir:
|
||||||
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
issue_lock_store.save_lock_file(
|
issue_lock_store.save_lock_file(
|
||||||
issue_lock_store.lock_file_path(
|
issue_lock_store.lock_file_path(
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
repo="Gitea-Tools", # #714 session-bound
|
||||||
issue_number=447,
|
issue_number=447,
|
||||||
lock_dir=lock_dir,
|
lock_dir=lock_dir,
|
||||||
),
|
),
|
||||||
@@ -4071,7 +4135,7 @@ class TestIssueLocking(unittest.TestCase):
|
|||||||
branch_name="feat/issue-447-lock-provenance",
|
branch_name="feat/issue-447-lock-provenance",
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
org="Scaled-Tech-Consulting",
|
org="Scaled-Tech-Consulting",
|
||||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
repo="Gitea-Tools", # #714 session-bound
|
||||||
worktree_path=worktree,
|
worktree_path=worktree,
|
||||||
lock_provenance=None,
|
lock_provenance=None,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
|||||||
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
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))
|
self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons))
|
||||||
|
|
||||||
@patch("threading.Thread")
|
def test_auto_restart_helper_removed_from_read_only_path(self):
|
||||||
@patch("os.utime")
|
"""#685: config-touch / os._exit self-recovery is no longer on the server."""
|
||||||
@patch("os.path.exists")
|
self.assertFalse(
|
||||||
@patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"})
|
hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"),
|
||||||
def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread):
|
"_trigger_mcp_auto_restart must not remain (side-effect-free resolver)",
|
||||||
mock_exists.return_value = True
|
)
|
||||||
gitea_mcp_server._restart_triggered = False
|
self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered"))
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""Merger lease acquisition + capability resolution tests (#718, #723)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_mcp_server as mcp_server
|
||||||
|
import reviewer_pr_lease as leases
|
||||||
|
import task_capability_map as tcm
|
||||||
|
|
||||||
|
|
||||||
|
HEAD = "a" * 40
|
||||||
|
LIVE_HEAD = HEAD
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergerLeaseAcquireTool(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._preflight_whoami_called = True
|
||||||
|
mcp_server._preflight_capability_called = True
|
||||||
|
mcp_server._preflight_resolved_role = "merger"
|
||||||
|
mcp_server._preflight_resolved_task = "acquire_merger_pr_lease"
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
leases.clear_session_lease()
|
||||||
|
|
||||||
|
def _merger_profile(self, **extra):
|
||||||
|
p = {
|
||||||
|
"username": "merger-user",
|
||||||
|
"profile_name": "prgs-merger",
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
p.update(extra)
|
||||||
|
return p
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_success(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
mock_api.side_effect = [
|
||||||
|
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
||||||
|
{"id": 456},
|
||||||
|
]
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"], res)
|
||||||
|
self.assertTrue(res["acquired"])
|
||||||
|
self.assertEqual(res["comment_id"], 456)
|
||||||
|
self.assertEqual(res["session_id"], "merger-session")
|
||||||
|
self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools")
|
||||||
|
body = mock_api.call_args_list[-1][0][3]["body"]
|
||||||
|
self.assertIn("reviewer_identity: merger-user", body)
|
||||||
|
self.assertIn("profile: prgs-merger", body)
|
||||||
|
session_lease = leases._SESSION_LEASE
|
||||||
|
self.assertIsNotNone(session_lease)
|
||||||
|
provenance = session_lease.get("lease_provenance") or {}
|
||||||
|
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_fails_if_active_exists(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
active_body = leases.format_lease_body(
|
||||||
|
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
pr_number=718,
|
||||||
|
issue_number=None,
|
||||||
|
reviewer_identity="other-user",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
session_id="other-session",
|
||||||
|
worktree="branches/review-1",
|
||||||
|
phase="claimed",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
target_branch="master",
|
||||||
|
target_branch_sha="b" * 40,
|
||||||
|
last_activity=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
_comments.return_value = [
|
||||||
|
{"id": 1, "body": active_body, "user": {"login": "other-user"}}
|
||||||
|
]
|
||||||
|
mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["acquired"])
|
||||||
|
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
|
||||||
|
self.assertEqual(
|
||||||
|
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
||||||
|
)
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
_verify.side_effect = RuntimeError("Branches-only mutation guard")
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertIn("Branches-only mutation guard", res["reasons"][0])
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify(
|
||||||
|
self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
"""Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet)."""
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
mock_api.side_effect = [
|
||||||
|
{"state": "open", "head": {"sha": LIVE_HEAD}},
|
||||||
|
{"id": 789},
|
||||||
|
]
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(res["success"], res)
|
||||||
|
kwargs = mock_verify.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(kwargs.get("repo"), "Gitea-Tools")
|
||||||
|
self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease")
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
def test_acquire_merger_requires_candidate_head(self, mock_profile):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=None,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("candidate_head is required" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_head_mismatch(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}}
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("does not match live PR head" in r for r in res["reasons"]))
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server.api_request")
|
||||||
|
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||||
|
@patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools"))
|
||||||
|
@patch("gitea_mcp_server.get_profile")
|
||||||
|
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||||
|
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||||
|
def test_acquire_merger_fails_closed_when_live_head_unresolvable(
|
||||||
|
self, _verify, _comments, mock_profile, _resolve, _auth, mock_api
|
||||||
|
):
|
||||||
|
"""Empty/missing live head must not silently proceed past exact-head gate."""
|
||||||
|
mock_profile.return_value = self._merger_profile()
|
||||||
|
# PR payload present but head.sha missing / empty / non-dict.
|
||||||
|
mock_api.return_value = {"state": "open", "head": {}}
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
session_id="merger-session",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"], res)
|
||||||
|
self.assertFalse(res.get("acquired"), res)
|
||||||
|
self.assertTrue(
|
||||||
|
any("live PR head could not be resolved" in r for r in res["reasons"]),
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
self.assertIsNone(res.get("live_head"))
|
||||||
|
self.assertEqual(res.get("candidate_head"), HEAD)
|
||||||
|
self.assertEqual(
|
||||||
|
[c for c in mock_api.call_args_list if c[0][0] == "POST"], []
|
||||||
|
)
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
# Completely empty GET /pulls body also fails closed.
|
||||||
|
mock_api.return_value = {}
|
||||||
|
res2 = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res2["success"], res2)
|
||||||
|
self.assertTrue(
|
||||||
|
any("live PR head could not be resolved" in r for r in res2["reasons"]),
|
||||||
|
res2,
|
||||||
|
)
|
||||||
|
self.assertIsNone(leases._SESSION_LEASE)
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server._profile_operation_gate")
|
||||||
|
def test_author_denied_merge_permission(self, mock_gate):
|
||||||
|
"""Author profile lacks gitea.pr.merge → fail closed before mutation."""
|
||||||
|
def gate(op):
|
||||||
|
if op == "gitea.pr.merge":
|
||||||
|
return [f"profile lacks {op}"]
|
||||||
|
return None
|
||||||
|
mock_gate.side_effect = gate
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
@patch("gitea_mcp_server._profile_operation_gate")
|
||||||
|
def test_reviewer_denied_merge_permission(self, mock_gate):
|
||||||
|
def gate(op):
|
||||||
|
if op == "gitea.pr.merge":
|
||||||
|
return [f"profile lacks {op}"]
|
||||||
|
return None
|
||||||
|
mock_gate.side_effect = gate
|
||||||
|
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||||
|
pr_number=718,
|
||||||
|
worktree="branches/issue-718",
|
||||||
|
candidate_head=HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilityMapLeaseTasks(unittest.TestCase):
|
||||||
|
def test_merger_acquire_map_entries(self):
|
||||||
|
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
||||||
|
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
||||||
|
self.assertEqual(tcm.required_role(task), "merger")
|
||||||
|
|
||||||
|
def test_reviewer_acquire_map_entries(self):
|
||||||
|
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
||||||
|
self.assertEqual(tcm.required_permission(task), "gitea.pr.comment")
|
||||||
|
self.assertEqual(tcm.required_role(task), "reviewer")
|
||||||
|
|
||||||
|
def test_adopt_merger_aliases(self):
|
||||||
|
for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"):
|
||||||
|
self.assertEqual(tcm.required_role(task), "merger")
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
mcp_server._process_boot_head_sha = None
|
||||||
|
if hasattr(mcp_server, "capability_stop_terminal"):
|
||||||
|
mcp_server.capability_stop_terminal.clear()
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
@patch.object(mcp_server.gitea_config, "load_config")
|
||||||
|
def test_resolve_acquire_reviewer_authorized(
|
||||||
|
self, mock_cfg, mock_profile, *_mocks
|
||||||
|
):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False):
|
||||||
|
for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||||
|
self.assertEqual(res["required_role_kind"], "reviewer", res)
|
||||||
|
self.assertEqual(
|
||||||
|
res["required_operation_permission"], "gitea.pr.comment", res
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("allowed_in_current_session"), res)
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
@patch.object(mcp_server.gitea_config, "load_config")
|
||||||
|
def test_resolve_acquire_reviewer_author_denied(
|
||||||
|
self, mock_cfg, mock_profile, *_mocks
|
||||||
|
):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.read",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
"prgs-reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.pr.comment", "gitea.pr.review"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="acquire_reviewer_pr_lease", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertFalse(res.get("allowed_in_current_session"), res)
|
||||||
|
self.assertEqual(res["required_role_kind"], "reviewer")
|
||||||
|
guidance = " ".join(res.get("task_role_guidance") or [])
|
||||||
|
self.assertTrue(
|
||||||
|
"cannot perform reviewer" in guidance.lower()
|
||||||
|
or "reviewer" in guidance.lower()
|
||||||
|
or res.get("stop_required"),
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-reviewer",
|
||||||
|
"role": "reviewer",
|
||||||
|
"allowed_operations": ["gitea.pr.comment"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
# Must not raise ValueError into the tool boundary.
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="some_made_up_task", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIsInstance(res, dict)
|
||||||
|
self.assertEqual(res.get("reason_code"), "unknown_task", res)
|
||||||
|
self.assertFalse(res.get("allowed_in_current_session"))
|
||||||
|
self.assertTrue(res.get("stop_required"))
|
||||||
|
self.assertIs(res.get("mutation_performed"), False)
|
||||||
|
self.assertNotIn("token", str(res).lower())
|
||||||
|
self.assertNotIn("password", str(res).lower())
|
||||||
|
guidance = " ".join(res.get("task_role_guidance") or [])
|
||||||
|
self.assertIn("Unknown task/action", guidance)
|
||||||
|
|
||||||
|
@patch.object(mcp_server, "record_mutation_authority", return_value=None)
|
||||||
|
@patch.object(mcp_server, "init_review_decision_lock", return_value=None)
|
||||||
|
@patch.object(mcp_server, "record_preflight_check", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_ensure_matching_profile", return_value=None)
|
||||||
|
@patch.object(mcp_server, "_authenticated_username", return_value="sysadmin")
|
||||||
|
@patch.object(mcp_server, "get_profile")
|
||||||
|
@patch.object(mcp_server.gitea_config, "load_config")
|
||||||
|
def test_resolve_merger_acquire_aliases(
|
||||||
|
self, mock_cfg, mock_profile, *_mocks
|
||||||
|
):
|
||||||
|
mock_profile.return_value = {
|
||||||
|
"profile_name": "prgs-merger",
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
mock_cfg.return_value = {
|
||||||
|
"profiles": {
|
||||||
|
"prgs-merger": {
|
||||||
|
"role": "merger",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False):
|
||||||
|
for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"):
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs")
|
||||||
|
self.assertEqual(res["required_role_kind"], "merger", res)
|
||||||
|
self.assertEqual(
|
||||||
|
res["required_operation_permission"], "gitea.pr.comment", res
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Merger lease adoption / recovery (#536)."""
|
"""Merger lease adoption / recovery (#536)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Operation-name normalization table and enforcement tests — issue #106.
|
"""Operation-name normalization table and enforcement tests — issue #106.
|
||||||
|
|
||||||
Covers the required matrix from #106:
|
Covers the required matrix from #106:
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
"""Tests for operation-scoped role selection and automatic dispatch switching (#228)."""
|
"""Tests for operation-scoped role selection (#228, updated by #714).
|
||||||
|
|
||||||
|
#714 removes silent automatic profile substitution. Capability resolution
|
||||||
|
and mutation gates evaluate only the active profile; explicit
|
||||||
|
``gitea_activate_profile`` is required to switch roles.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
@@ -15,6 +20,7 @@ from reviewer_worktree import assess_author_worktree_continuity
|
|||||||
|
|
||||||
CONFIG_TEST = {
|
CONFIG_TEST = {
|
||||||
"version": 2,
|
"version": 2,
|
||||||
|
"allow_runtime_switching": True,
|
||||||
"contexts": {
|
"contexts": {
|
||||||
"ctx": {
|
"ctx": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -30,9 +36,11 @@ CONFIG_TEST = {
|
|||||||
"context": "ctx",
|
"context": "ctx",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
"username": "author-user",
|
"username": "author-user",
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.issue.create", "gitea.pr.create", "gitea.branch.push", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
|
||||||
"execution_profile": "author-profile"
|
"execution_profile": "author-profile"
|
||||||
},
|
},
|
||||||
"reviewer-profile": {
|
"reviewer-profile": {
|
||||||
@@ -40,9 +48,11 @@ CONFIG_TEST = {
|
|||||||
"context": "ctx",
|
"context": "ctx",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
"username": "reviewer-user",
|
"username": "reviewer-user",
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "913443/eAgenda"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +67,15 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
self._remotes_patch.start()
|
self._remotes_patch.start()
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._url = patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"_local_git_remote_url",
|
||||||
|
side_effect=lambda n: {
|
||||||
|
"prgs": "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||||
|
"dadeschools": "https://gitea.dadeschools.net/913443/eAgenda.git",
|
||||||
|
}.get(n),
|
||||||
|
)
|
||||||
|
self._url.start()
|
||||||
gitea_config._active_profile_override = None
|
gitea_config._active_profile_override = None
|
||||||
mcp_server._MUTATION_AUTHORITY = None
|
mcp_server._MUTATION_AUTHORITY = None
|
||||||
self._dir = tempfile.TemporaryDirectory()
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
@@ -64,6 +83,11 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
self._write_config(CONFIG_TEST)
|
self._write_config(CONFIG_TEST)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
try:
|
||||||
|
self._url.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
self._remotes_patch.stop()
|
self._remotes_patch.stop()
|
||||||
mcp_server._IDENTITY_CACHE.clear()
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
gitea_config._active_profile_override = None
|
gitea_config._active_profile_override = None
|
||||||
@@ -85,62 +109,71 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_auto_switch_to_reviewer(self, mock_api):
|
def test_no_auto_switch_to_reviewer(self, mock_api):
|
||||||
# mock identity resolution to return username matching profile
|
# #714: capability resolution must not silently switch author → reviewer
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
# initially we are author-profile
|
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
|
self.assertEqual(mcp_server.get_profile()["profile_name"], "author-profile")
|
||||||
|
|
||||||
# resolve a reviewer task (review_pr)
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
|
||||||
# verify it automatically switched to reviewer-profile
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertFalse(res["available_in_session"])
|
||||||
self.assertTrue(res["available_in_session"])
|
self.assertEqual(res["active_profile"], "author-profile")
|
||||||
self.assertEqual(res["active_profile"], "reviewer-profile")
|
self.assertEqual(res["active_identity"], "author-user")
|
||||||
self.assertEqual(res["active_identity"], "reviewer-user")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
self.assertIn("reviewer-profile", res["matching_configured_profile"])
|
||||||
|
self.assertFalse(res.get("auto_profile_substitution", True))
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_auto_switch_to_author(self, mock_api):
|
def test_no_auto_switch_to_author(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||||
# initially we are reviewer-profile
|
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
|
|
||||||
# resolve an author task (create_issue)
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs")
|
||||||
|
|
||||||
# verify it automatically switched to author-profile
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertFalse(res["available_in_session"])
|
||||||
self.assertTrue(res["available_in_session"])
|
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||||
self.assertEqual(res["active_profile"], "author-profile")
|
self.assertEqual(res["active_identity"], "reviewer-user")
|
||||||
self.assertEqual(res["active_identity"], "author-user")
|
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertIn("author-profile", res["matching_configured_profile"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_restart_required_when_unattached(self, mock_api):
|
def test_explicit_activate_profile_still_works(self, mock_api):
|
||||||
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
|
)
|
||||||
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
|
act = mcp_server.gitea_activate_profile(
|
||||||
|
profile_name="reviewer-profile", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertTrue(act["success"])
|
||||||
|
self.assertEqual(act["after_profile"], "reviewer-profile")
|
||||||
|
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
self.assertEqual(res["active_profile"], "reviewer-profile")
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
def test_denied_when_matching_profile_token_missing(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
|
mock_api.side_effect = lambda method, url, header: {"login": "author-user"}
|
||||||
# launch without reviewer token in env
|
# launch without reviewer token in env
|
||||||
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
|
with patch.dict(os.environ, self._env("author-profile", with_reviewer_token=False)):
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
# resolve a reviewer task (review_pr)
|
|
||||||
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs")
|
||||||
|
|
||||||
# verify it did NOT switch and reports restart_required
|
|
||||||
self.assertFalse(res["allowed_in_current_session"])
|
self.assertFalse(res["allowed_in_current_session"])
|
||||||
self.assertFalse(res["available_in_session"])
|
self.assertFalse(res["available_in_session"])
|
||||||
self.assertTrue(res["configured"])
|
self.assertTrue(res["configured"])
|
||||||
self.assertTrue(res["restart_required"])
|
|
||||||
self.assertTrue(res["stop_required"])
|
self.assertTrue(res["stop_required"])
|
||||||
self.assertIn("Reviewer profile exists but MCP server was added after session startup and is not attached", res["reason"])
|
self.assertEqual(res["active_profile"], "author-profile")
|
||||||
|
|
||||||
def test_author_continuity_dirty_worktree(self):
|
def test_author_continuity_dirty_worktree(self):
|
||||||
# author is allowed to keep dirty worktree
|
# author is allowed to keep dirty worktree
|
||||||
@@ -161,20 +194,21 @@ class TestOperationScopedRoles(unittest.TestCase):
|
|||||||
self.assertFalse(res2["proven"])
|
self.assertFalse(res2["proven"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
def test_mutating_actions_auto_switch(self, mock_api):
|
def test_mutating_actions_do_not_auto_switch(self, mock_api):
|
||||||
mock_api.side_effect = lambda method, url, header: (
|
mock_api.side_effect = lambda method, url, header: (
|
||||||
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
{"login": "reviewer-user"} if "reviewer-pass" in str(header) else {"login": "author-user"}
|
||||||
)
|
)
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
# verify we are author-profile
|
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
# call a reviewer mutation check helper (like _profile_permission_block with reviewer permission)
|
blocked = mcp_server._profile_permission_block(
|
||||||
blocked = mcp_server._profile_permission_block("gitea.pr.merge", remote="prgs")
|
"gitea.pr.merge", remote="prgs"
|
||||||
self.assertIsNone(blocked) # should switch to reviewer-profile and allow it (no permission block)
|
)
|
||||||
|
self.assertIsNotNone(blocked)
|
||||||
|
self.assertFalse(blocked.get("success", True))
|
||||||
|
|
||||||
# verify we dynamically switched to reviewer-profile
|
# profile must remain author — no silent substitution
|
||||||
self.assertEqual(gitea_config.selected_profile_name(), "reviewer-profile")
|
self.assertEqual(gitea_config.selected_profile_name(), "author-profile")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for the operator guide / project skills MCP tools (#128).
|
"""Tests for the operator guide / project skills MCP tools (#128).
|
||||||
|
|
||||||
Read-only capability-discovery tools: mcp_get_control_plane_guide,
|
Read-only capability-discovery tools: mcp_get_control_plane_guide,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Post-merge moot reviewer-lease handling (#515).
|
"""Post-merge moot reviewer-lease handling (#515).
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""#727 / PR #728: author ownership when tracking issue number != PR number.
|
||||||
|
|
||||||
|
Regression for REQUEST_CHANGES: gitea_update_pr_branch_by_merge must not require
|
||||||
|
issue_number == pr_number. Ownership is proven via linked issue + lock/branch
|
||||||
|
context and fails closed for unrelated issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import issue_lock_store
|
||||||
|
import gitea_mcp_server as mcp
|
||||||
|
|
||||||
|
|
||||||
|
def _live_lock(
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str = "feat/issue-727-pr-sync-status",
|
||||||
|
worktree_path: str = "/tmp/branches/issue-727-pr-sync-status",
|
||||||
|
remote: str = "prgs",
|
||||||
|
org: str = "Scaled-Tech-Consulting",
|
||||||
|
repo: str = "Gitea-Tools",
|
||||||
|
) -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"acquired_at": now.isoformat(),
|
||||||
|
"expires_at": (now + timedelta(hours=2)).isoformat(),
|
||||||
|
"owner_pid": os.getpid(),
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="gitea-issue-locks-")
|
||||||
|
self.lock_dir = self._tmp.name
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# Clear session pointer for this process.
|
||||||
|
try:
|
||||||
|
ptr = issue_lock_store.session_pointer_path(self.lock_dir)
|
||||||
|
if os.path.isfile(ptr):
|
||||||
|
os.unlink(ptr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
os.environ.pop("GITEA_ISSUE_LOCK_DIR", None)
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_issue_727_pr_728_session_lock_accepted(self):
|
||||||
|
"""Tracking issue #727 lock is valid ownership for PR #728."""
|
||||||
|
lock = _live_lock(issue_number=727)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727\n\nNative PR sync lifecycle.",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertTrue(result["has_author_lock"])
|
||||||
|
self.assertEqual(result["matched_issue"], 727)
|
||||||
|
self.assertEqual(result["matched_via"], "session_lock")
|
||||||
|
self.assertIn(727, result["linked_issues"])
|
||||||
|
|
||||||
|
def test_issue_727_pr_728_durable_lock_accepted(self):
|
||||||
|
lock = _live_lock(issue_number=727)
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
issue_number=727,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
issue_lock_store.save_lock_file(path, lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Fixes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertEqual(result["matched_issue"], 727)
|
||||||
|
self.assertEqual(result["matched_via"], "durable_lock")
|
||||||
|
|
||||||
|
def test_legacy_same_number_still_accepted(self):
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=100,
|
||||||
|
branch_name="fix/issue-100",
|
||||||
|
worktree_path="/tmp/branches/issue-100",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=100,
|
||||||
|
pr_title="fix something",
|
||||||
|
pr_body="Closes #100",
|
||||||
|
source_branch="fix/issue-100",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertEqual(result["matched_issue"], 100)
|
||||||
|
|
||||||
|
def test_unrelated_issue_lock_rejected(self):
|
||||||
|
"""Lock for issue #999 must not authorize PR #728 linked only to #727."""
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=999,
|
||||||
|
branch_name="feat/issue-999",
|
||||||
|
worktree_path="/tmp/branches/issue-999",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
issue_number=999,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
issue_lock_store.save_lock_file(path, lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertFalse(result["has_author_lock"])
|
||||||
|
self.assertIsNone(result["matched_issue"])
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
|
||||||
|
def test_branch_mismatch_on_session_lock_fail_closed(self):
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=727,
|
||||||
|
branch_name="feat/other-branch",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertTrue(any("branch" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_no_lock_fail_closed(self):
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertTrue(any("no live author issue lock" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Regression coverage for the remote/repo mismatch guard (#530).
|
"""Regression coverage for the remote/repo mismatch guard (#530).
|
||||||
|
|
||||||
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
|
Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet``
|
||||||
@@ -162,12 +166,12 @@ class TestResolveServerWiring(unittest.TestCase):
|
|||||||
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
|
self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original)
|
||||||
self.server.REMOTES["prgs"] = {**original, "repo": repo}
|
self.server.REMOTES["prgs"] = {**original, "repo": repo}
|
||||||
|
|
||||||
def test_resolve_blocks_on_default_repo_mismatch(self):
|
def test_resolve_prefers_workspace_over_timesheet_default(self):
|
||||||
|
"""#714: bare remote=prgs must use workspace Gitea-Tools, not REMOTES Timesheet."""
|
||||||
self._set_prgs_default_repo("Timesheet")
|
self._set_prgs_default_repo("Timesheet")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
host, org, repo = self.server._resolve("prgs", None, None, None)
|
||||||
self.server._resolve("prgs", None, None, None)
|
self.assertEqual(org, "Scaled-Tech-Consulting")
|
||||||
self.assertIn("Timesheet", str(ctx.exception))
|
self.assertEqual(repo, "Gitea-Tools")
|
||||||
self.assertIn("Gitea-Tools", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_resolve_allows_explicit_org_and_repo(self):
|
def test_resolve_allows_explicit_org_and_repo(self):
|
||||||
self._set_prgs_default_repo("Timesheet")
|
self._set_prgs_default_repo("Timesheet")
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertEqual(res["required_role_kind"], "author")
|
self.assertEqual(res["required_role_kind"], "author")
|
||||||
self.assertTrue(res["allowed_in_current_session"])
|
self.assertTrue(res["allowed_in_current_session"])
|
||||||
|
|
||||||
@patch("mcp_server.api_request", return_value={"login": "author-user"})
|
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
|
||||||
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
|
||||||
with patch.dict(os.environ, self._env("reviewer-profile")):
|
with patch.dict(os.environ, self._env("reviewer-profile")):
|
||||||
res = mcp_server.gitea_resolve_task_capability(
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
@@ -231,9 +231,16 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
|
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
|
||||||
|
|
||||||
def test_resolve_unknown_task_fails_closed(self):
|
def test_resolve_unknown_task_fails_closed(self):
|
||||||
|
# #723: unknown tasks return structured unknown_task (no ValueError escape).
|
||||||
with patch.dict(os.environ, self._env("author-profile")):
|
with patch.dict(os.environ, self._env("author-profile")):
|
||||||
with self.assertRaises(ValueError):
|
res = mcp_server.gitea_resolve_task_capability(
|
||||||
mcp_server.gitea_resolve_task_capability(task="invalid_task_xyz", remote="prgs")
|
task="invalid_task_xyz", remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertIsInstance(res, dict)
|
||||||
|
self.assertEqual(res.get("reason_code"), "unknown_task", res)
|
||||||
|
self.assertFalse(res.get("allowed_in_current_session"))
|
||||||
|
self.assertTrue(res.get("stop_required"))
|
||||||
|
self.assertIs(res.get("mutation_performed"), False)
|
||||||
|
|
||||||
# Additional regression tests per #145 for permission boundaries and structured guidance
|
# Additional regression tests per #145 for permission boundaries and structured guidance
|
||||||
def test_issue_comment_does_not_imply_close(self):
|
def test_issue_comment_does_not_imply_close(self):
|
||||||
@@ -439,8 +446,11 @@ class TestResolveTaskCapability(unittest.TestCase):
|
|||||||
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs")
|
||||||
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
|
||||||
for unknown in ("close_pull_request", "close", "pr_close"):
|
for unknown in ("close_pull_request", "close", "pr_close"):
|
||||||
with self.assertRaises(ValueError):
|
unk = mcp_server.gitea_resolve_task_capability(
|
||||||
mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs")
|
task=unknown, remote="prgs"
|
||||||
|
)
|
||||||
|
self.assertEqual(unk.get("reason_code"), "unknown_task", unk)
|
||||||
|
self.assertFalse(unk.get("allowed_in_current_session"))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+101
-2
@@ -68,8 +68,6 @@ class TestReviewDrafts(unittest.TestCase):
|
|||||||
|
|
||||||
@patch("gitea_mcp_server.api_request")
|
@patch("gitea_mcp_server.api_request")
|
||||||
def test_save_draft_success(self, mock_api):
|
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 = {
|
mock_api.return_value = {
|
||||||
"head": {"sha": "headsha1234567890"},
|
"head": {"sha": "headsha1234567890"},
|
||||||
"base": {"ref": "master", "sha": "basesha0987654321"},
|
"base": {"ref": "master", "sha": "basesha0987654321"},
|
||||||
@@ -285,3 +283,104 @@ class TestReviewDrafts(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(res.get("success"))
|
self.assertFalse(res.get("success"))
|
||||||
self.assertIn("worktree binding mismatch", res.get("reasons")[0])
|
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 [])))
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
"""Tests for reviewer/author namespace mismatch walls (#209)."""
|
"""Tests for reviewer/author namespace mismatch walls (#209)."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -36,6 +40,7 @@ CONFIG = {
|
|||||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-author",
|
"execution_profile": "prgs-author",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reviewer": {
|
"prgs-reviewer": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -51,6 +56,7 @@ CONFIG = {
|
|||||||
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-reviewer",
|
"execution_profile": "prgs-reviewer",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reviewer-issue-writer": {
|
"prgs-reviewer-issue-writer": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -63,12 +69,13 @@ CONFIG = {
|
|||||||
],
|
],
|
||||||
"forbidden_operations": ["gitea.pr.create"],
|
"forbidden_operations": ["gitea.pr.create"],
|
||||||
"execution_profile": "prgs-reviewer-issue-writer",
|
"execution_profile": "prgs-reviewer-issue-writer",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
}
|
}
|
||||||
|
|
||||||
ISSUE_WRITE_ENV = {"GITEA_ALLOWED_OPERATIONS": "gitea.issue.create"}
|
ISSUE_WRITE_ENV = {} # config-backed; operations come from profile allowlist (#714)
|
||||||
|
|
||||||
|
|
||||||
class NamespaceGateBase(unittest.TestCase):
|
class NamespaceGateBase(unittest.TestCase):
|
||||||
@@ -199,7 +206,7 @@ class TestMutationAuditFields(NamespaceGateBase):
|
|||||||
def test_successful_create_issue_audit_includes_namespace_fields(
|
def test_successful_create_issue_audit_includes_namespace_fields(
|
||||||
self, _auth, mock_api, _get_all, _role
|
self, _auth, mock_api, _get_all, _role
|
||||||
):
|
):
|
||||||
env = {**self._env("prgs-author", audit=True), **ISSUE_WRITE_ENV}
|
env = self._env("prgs-author", audit=True)
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
mcp_server.gitea_create_issue(title="audited", remote="prgs")
|
mcp_server.gitea_create_issue(title="audited", remote="prgs")
|
||||||
with open(self.audit_path, encoding="utf-8") as fh:
|
with open(self.audit_path, encoding="utf-8") as fh:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ CONFIG = {
|
|||||||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-author",
|
"execution_profile": "prgs-author",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reviewer": {
|
"prgs-reviewer": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -51,6 +52,7 @@ CONFIG = {
|
|||||||
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
"gitea.pr.create", "gitea.branch.push", "gitea.issue.create",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-reviewer",
|
"execution_profile": "prgs-reviewer",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-merger": {
|
"prgs-merger": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -65,6 +67,7 @@ CONFIG = {
|
|||||||
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
|
"gitea.pr.create", "gitea.branch.push", "gitea.pr.approve",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-merger",
|
"execution_profile": "prgs-merger",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
"prgs-reconciler": {
|
"prgs-reconciler": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -81,6 +84,7 @@ CONFIG = {
|
|||||||
"gitea.branch.push",
|
"gitea.branch.push",
|
||||||
],
|
],
|
||||||
"execution_profile": "prgs-reconciler",
|
"execution_profile": "prgs-reconciler",
|
||||||
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"],
|
"allowed_operations": ["gitea.read", "gitea.pr.create", "gitea.branch.push"],
|
||||||
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
"execution_profile": "author-profile"
|
"execution_profile": "author-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"reviewer-profile": {
|
"reviewer-profile": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -45,7 +46,8 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
|
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
|
||||||
"execution_profile": "reviewer-profile"
|
"execution_profile": "reviewer-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"merger-profile": {
|
"merger-profile": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -55,7 +57,8 @@ CONFIG_SWITCHING_DISABLED = {
|
|||||||
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
|
||||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
|
||||||
"execution_profile": "merger-profile"
|
"execution_profile": "merger-profile",
|
||||||
|
"allowed_repositories": ["Example-Org/Example-Repo"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"""Stale #332 review-decision lock cleanup (#594).
|
|
||||||
|
|
||||||
Proves:
|
|
||||||
a. active terminal locks still block unrelated terminal reviews
|
|
||||||
b. merged/closed PR locks can be cleared canonically
|
|
||||||
c. cleanup cannot bypass review gates on open PRs
|
|
||||||
d. after moot cleanup, mark_ready for a different PR can proceed
|
|
||||||
(PR #592-style after PR #586-style stale approve)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -188,20 +184,32 @@ class TestActiveHardStopPreserved(unittest.TestCase):
|
|||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
def test_open_approve_still_blocks_other_pr_mark_ready(self):
|
def test_open_approve_blocks_same_pr_not_other_pr_mark_ready(self):
|
||||||
_seed([APPROVED_OPEN])
|
_seed([APPROVED_OPEN]) # approve on PR #700
|
||||||
reasons = mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready")
|
# Same PR still hard-stopped for mark_ready.
|
||||||
self.assertTrue(reasons)
|
reasons_same = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
self.assertIn("#332", reasons[0])
|
700, "mark_ready"
|
||||||
self.assertIn("gitea_cleanup_stale_review_decision_lock", reasons[0])
|
)
|
||||||
|
self.assertTrue(reasons_same)
|
||||||
|
self.assertIn("#332", reasons_same[0])
|
||||||
|
# #693: other PR may mark_ready without cleanup.
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
def test_request_changes_still_blocks_everything(self):
|
def test_request_changes_still_blocks_same_pr(self):
|
||||||
_seed([RC_OPEN])
|
_seed([RC_OPEN]) # request_changes on PR #701
|
||||||
for op in ("merge", "mark_ready", "review"):
|
for op in ("merge", "mark_ready", "review"):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(592, op),
|
mcp_server.terminal_review_hard_stop_reasons(701, op),
|
||||||
msg=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).
|
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
|
||||||
|
|
||||||
Extends the single-terminal-decision machinery (#211): after a live
|
Extends the single-terminal-decision machinery (#211): after a live
|
||||||
REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence
|
REQUEST_CHANGES on a PR head the same-head path must stop; after an APPROVE
|
||||||
for that same PR may continue; a different PR can never be reviewed or
|
only the merge sequence for that same PR may continue; #693 allows a
|
||||||
merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is
|
*different* PR to be mark_ready/review in the same durable lock (cross-PR
|
||||||
suppressed.
|
isolation); duplicate REQUEST_CHANGES at an unchanged head is suppressed.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
@@ -15,7 +15,10 @@ import mcp_server
|
|||||||
HEAD_SHA = "a" * 40
|
HEAD_SHA = "a" * 40
|
||||||
|
|
||||||
|
|
||||||
def _lock(mutations=None, correction=False):
|
def _lock(mutations=None, correction=False, correction_pr=None):
|
||||||
|
mutations = list(mutations or [])
|
||||||
|
if correction and correction_pr is None and mutations:
|
||||||
|
correction_pr = mutations[-1].get("pr_number")
|
||||||
return {
|
return {
|
||||||
"task": "review_pr",
|
"task": "review_pr",
|
||||||
"remote": "prgs",
|
"remote": "prgs",
|
||||||
@@ -29,9 +32,11 @@ def _lock(mutations=None, correction=False):
|
|||||||
"ready_remote": None,
|
"ready_remote": None,
|
||||||
"ready_org": None,
|
"ready_org": None,
|
||||||
"ready_repo": None,
|
"ready_repo": None,
|
||||||
"live_mutations": list(mutations or []),
|
"live_mutations": mutations,
|
||||||
"correction_authorized": correction,
|
"correction_authorized": correction,
|
||||||
"correction_reason": None,
|
"correction_reason": "test" if correction else None,
|
||||||
|
"correction_pr_number": correction_pr if correction else None,
|
||||||
|
"correction_head_sha": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,25 +69,35 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||||
|
|
||||||
def test_request_changes_blocks_everything(self):
|
def test_request_changes_blocks_same_pr_allows_other_pr_review(self):
|
||||||
_seed([RC_A])
|
_seed([RC_A])
|
||||||
|
# Same PR: still hard-stopped for mark_ready/review/merge.
|
||||||
for op in ("merge", "mark_ready", "review"):
|
for op in ("merge", "mark_ready", "review"):
|
||||||
for pr in (5, 6):
|
reasons = mcp_server.terminal_review_hard_stop_reasons(5, op)
|
||||||
reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op)
|
self.assertTrue(reasons, f"{op}/5")
|
||||||
self.assertTrue(reasons, f"{op}/{pr}")
|
self.assertIn("request_changes on PR #5", reasons[0])
|
||||||
self.assertIn("request_changes on PR #5", reasons[0])
|
self.assertIn("#332", reasons[0])
|
||||||
self.assertIn("#332", reasons[0])
|
# #693: different PR may mark_ready / review (not merge via hard-stop alone).
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||||
|
# Merge of an unapproved other PR remains blocked by hard-stop path
|
||||||
|
# (last terminal is not approve of PR 6).
|
||||||
|
self.assertTrue(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||||
|
|
||||||
def test_approve_allows_same_pr_merge_only(self):
|
def test_approve_allows_same_pr_merge_and_other_pr_review(self):
|
||||||
_seed([APPROVED_A])
|
_seed([APPROVED_A])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||||
self.assertTrue(
|
# #693 cross-PR isolation: other PR formal review path is open.
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
|
self.assertEqual(
|
||||||
self.assertTrue(
|
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
|
self.assertEqual(
|
||||||
|
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||||
|
|
||||||
def test_correction_reopens_review_path_only(self):
|
def test_correction_reopens_review_path_only(self):
|
||||||
_seed([RC_A], correction=True)
|
_seed([RC_A], correction=True)
|
||||||
@@ -90,9 +105,11 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
|||||||
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
|
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
|
||||||
# correction never opens a cross-PR merge
|
# scoped correction never opens a cross-PR merge
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
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):
|
class TestMergeHardStopWiring(unittest.TestCase):
|
||||||
@@ -126,14 +143,29 @@ class TestMarkFinalHardStopWiring(unittest.TestCase):
|
|||||||
mcp_server._save_review_decision_lock(None)
|
mcp_server._save_review_decision_lock(None)
|
||||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||||
|
|
||||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
def test_mark_ready_same_pr_blocked_after_terminal_mutation(self):
|
||||||
_seed([RC_A])
|
_seed([RC_A])
|
||||||
result = mcp_server.gitea_mark_final_review_decision(
|
result = mcp_server.gitea_mark_final_review_decision(
|
||||||
pr_number=6, action="approve", remote="prgs")
|
pr_number=5, action="approve", remote="prgs",
|
||||||
|
expected_head_sha=HEAD_SHA)
|
||||||
self.assertFalse(result["marked_ready"])
|
self.assertFalse(result["marked_ready"])
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any("#332" in r for r in result["reasons"]), result["reasons"])
|
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):
|
def _feedback(blocking, stale=False, success=True):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Tests for canonical workflow skill naming and preflight (#551)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
Reference in New Issue
Block a user