Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899ef8ec7f | ||
|
|
58d6b5844f | ||
|
|
c1c61e9b15 | ||
|
|
fca3296883 | ||
|
|
d7b0b0e772 | ||
|
|
83bc7246ff | ||
|
|
29c2cf2ef6 | ||
|
|
4a160e0e0c | ||
|
|
14d885b2d2 | ||
|
|
a3dbf668fd | ||
|
|
fb9191e559 | ||
|
|
07083eae09 | ||
|
|
d867acd9db | ||
|
|
a19af4de22 | ||
|
|
751403d12b | ||
|
|
3e78db5d28 | ||
|
|
e33d8874a5 | ||
|
|
98fbfb3de7 | ||
|
|
8814c04e3a | ||
|
|
7c77df0c52 | ||
|
|
c54f41c38d | ||
|
|
f34ec86b90 | ||
|
|
7cb65028fd | ||
|
|
f65069d22d | ||
|
|
9617b64a77 | ||
|
|
4f894009f6 | ||
|
|
acf2eaec01 | ||
|
|
e8ca5202a0 | ||
|
|
99640f90d0 | ||
|
|
1421fa8568 | ||
|
|
9e144db75c | ||
|
|
c780ded653 | ||
|
|
67e4a2b5e9 | ||
|
|
ba7915452e | ||
|
|
293808b42d | ||
|
|
970e68bddb | ||
|
|
80f59b334e | ||
|
|
77746b08fc | ||
|
|
1c37e62014 | ||
|
|
137426f7ad | ||
|
|
5b0d7aed0a | ||
|
|
1ab384adc9 | ||
|
|
573e721437 | ||
|
|
2b359e0c26 | ||
|
|
9cb12ee0f4 | ||
|
|
ec5cf67771 | ||
|
|
c2fc2683b9 | ||
|
|
889931d553 | ||
|
|
6b675f5c83 | ||
|
|
b4d0cb22e4 | ||
|
|
1844e29880 | ||
|
|
4a63578003 | ||
|
|
c7a444eb4b | ||
|
|
8cac50b2e7 | ||
|
|
3fd02a3c63 | ||
|
|
ea8e186549 | ||
|
|
78cc37a977 | ||
|
|
23e366d9d6 |
@@ -39,6 +39,27 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
|
|||||||
# only — never the token value. Surfaced by gitea_get_profile.
|
# only — never the token value. Surfaced by gitea_get_profile.
|
||||||
GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||||
|
|
||||||
|
# ── Optional self-hosted Sentry observability (#606) ────────────────────────
|
||||||
|
# Emits runtime errors, fail-closed workflow blockers, lease/terminal-lock/
|
||||||
|
# stale-runtime collisions, and watchdog cron check-ins to a SELF-HOSTED Sentry
|
||||||
|
# (https://sentry.prgs.cc/) — never Sentry Cloud. Gitea stays the source of
|
||||||
|
# truth; Sentry is observe-only. OFF by default: with MCP_SENTRY_ENABLED unset
|
||||||
|
# or SENTRY_DSN empty, nothing is initialised and no events are sent.
|
||||||
|
#
|
||||||
|
# Master gate. Truthy = 1/true/yes/on. Both this AND SENTRY_DSN are required.
|
||||||
|
MCP_SENTRY_ENABLED=0
|
||||||
|
# DSN for the self-hosted project (create a `gitea-tools-mcp` project in
|
||||||
|
# https://sentry.prgs.cc/ and copy its DSN). Never commit a real DSN.
|
||||||
|
SENTRY_DSN=
|
||||||
|
# Deployment environment tag (local/dev/prod). Defaults to "development".
|
||||||
|
SENTRY_ENVIRONMENT=development
|
||||||
|
# Optional release identifier (e.g. a git SHA or version string).
|
||||||
|
SENTRY_RELEASE=
|
||||||
|
# Performance-trace sample rate, 0.0–1.0 (clamped). Default 0.0 (traces off).
|
||||||
|
MCP_SENTRY_TRACES_SAMPLE_RATE=0.0
|
||||||
|
# Set to 1 to forward Python logs to Sentry as structured logs. Default off.
|
||||||
|
MCP_SENTRY_ENABLE_LOGS=0
|
||||||
|
|
||||||
# Optional canonical runtime-profile config (#19). Instead of the fields above,
|
# Optional canonical runtime-profile config (#19). Instead of the fields above,
|
||||||
# point every LLM launcher at ONE JSON file of named profiles and select one.
|
# point every LLM launcher at ONE JSON file of named profiles and select one.
|
||||||
# Secrets are referenced (keychain id / env var name), never inlined. See
|
# Secrets are referenced (keychain id / env var name), never inlined. See
|
||||||
@@ -55,3 +76,12 @@ GITEA_MCP_PROFILE=prgs
|
|||||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||||
|
|
||||||
|
# Durable HMAC key for irrecoverable decision-lock provenance artifacts (#709 F4).
|
||||||
|
# REQUIRED in production native MCP processes that mint or verify recovery
|
||||||
|
# authorization (reconciler + merger must share the same key). Hex (preferred),
|
||||||
|
# base64, or utf-8 literal (>=16 bytes). Never generate an ephemeral per-process
|
||||||
|
# key — cross-process / post-restart verification would fail closed.
|
||||||
|
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||||
|
# Optional key version string bound into the signature (for rotation).
|
||||||
|
# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION=v1
|
||||||
|
|||||||
@@ -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, []
|
||||||
@@ -7,6 +7,19 @@ from typing import Any
|
|||||||
|
|
||||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
|
|
||||||
|
# Evidence / preservation branches must never be removed by cleanup tools
|
||||||
|
# (e.g. chore/issue-681-preserve-review-session-wip).
|
||||||
|
_PRESERVATION_MARKERS = ("preserve", "preservation", "evidence")
|
||||||
|
|
||||||
|
|
||||||
|
def is_preservation_or_evidence_branch(branch: str | None) -> bool:
|
||||||
|
"""Return True when *branch* is a preservation/evidence ref that must stay."""
|
||||||
|
if not branch:
|
||||||
|
return False
|
||||||
|
name = str(branch).lower()
|
||||||
|
return any(marker in name for marker in _PRESERVATION_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
_RAW_BRANCH_DELETE_PATTERNS = (
|
_RAW_BRANCH_DELETE_PATTERNS = (
|
||||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
||||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
||||||
@@ -72,6 +85,11 @@ def assess_merged_pr_branch_cleanup(
|
|||||||
reasons.append("PR head branch is missing")
|
reasons.append("PR head branch is missing")
|
||||||
if head_branch in protected:
|
if head_branch in protected:
|
||||||
reasons.append(f"branch '{head_branch}' is protected")
|
reasons.append(f"branch '{head_branch}' is protected")
|
||||||
|
if is_preservation_or_evidence_branch(head_branch):
|
||||||
|
reasons.append(
|
||||||
|
f"branch '{head_branch}' is a preservation/evidence branch and "
|
||||||
|
"cannot be deleted through merged-PR cleanup"
|
||||||
|
)
|
||||||
if head_branch in open_pr_heads:
|
if head_branch in open_pr_heads:
|
||||||
reasons.append("an open PR still references this head branch")
|
reasons.append("an open PR still references this head branch")
|
||||||
if head_on_target is False:
|
if head_on_target is False:
|
||||||
@@ -96,3 +114,490 @@ def assess_merged_pr_branch_cleanup(
|
|||||||
"block_reasons": reasons,
|
"block_reasons": reasons,
|
||||||
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #687 remediation: post-delete readback + active ownership protection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
READBACK_NOT_FOUND = "not_found"
|
||||||
|
READBACK_EXISTS = "exists"
|
||||||
|
READBACK_AUTHENTICATION = "authentication_error"
|
||||||
|
READBACK_AUTHORIZATION = "authorization_error"
|
||||||
|
READBACK_TRANSPORT = "transport_error"
|
||||||
|
READBACK_UNEXPECTED = "unexpected_response"
|
||||||
|
READBACK_AMBIGUOUS_404 = "ambiguous_not_found"
|
||||||
|
|
||||||
|
# Scope of a 404: only branch-scoped absence may set verified_absent.
|
||||||
|
NOT_FOUND_SCOPE_BRANCH = "branch"
|
||||||
|
NOT_FOUND_SCOPE_REPOSITORY = "repository"
|
||||||
|
NOT_FOUND_SCOPE_HOST = "host"
|
||||||
|
NOT_FOUND_SCOPE_UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||||
|
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||||
|
ERROR_CLASS_TRANSPORT = "transport"
|
||||||
|
ERROR_CLASS_UNEXPECTED = "unexpected"
|
||||||
|
|
||||||
|
OWNERSHIP_CATEGORY_AUTHOR_SESSION = "author_session"
|
||||||
|
OWNERSHIP_CATEGORY_AUTHOR_LEASE = "author_lease"
|
||||||
|
OWNERSHIP_CATEGORY_REVIEWER_LEASE = "reviewer_lease"
|
||||||
|
OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease"
|
||||||
|
OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease"
|
||||||
|
OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease"
|
||||||
|
OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding"
|
||||||
|
OWNERSHIP_CATEGORY_INVENTORY_ERROR = "ownership_inventory_error"
|
||||||
|
|
||||||
|
_ROLE_TO_OWNERSHIP_CATEGORY = {
|
||||||
|
"author": OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
|
"reviewer": OWNERSHIP_CATEGORY_REVIEWER_LEASE,
|
||||||
|
"merger": OWNERSHIP_CATEGORY_MERGER_LEASE,
|
||||||
|
"controller": OWNERSHIP_CATEGORY_CONTROLLER_LEASE,
|
||||||
|
"reconciler": OWNERSHIP_CATEGORY_RECONCILER_LEASE,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ACTIVE_OWNERSHIP_STATUSES = frozenset(
|
||||||
|
{"active", "live", "claimed", "in_progress", "working", "pushing", "pushed"}
|
||||||
|
)
|
||||||
|
_TERMINAL_OWNERSHIP_STATUSES = frozenset(
|
||||||
|
{"released", "abandoned", "done", "blocked", "terminal", "closed"}
|
||||||
|
)
|
||||||
|
_EXPIRED_STATUSES = frozenset({"expired"})
|
||||||
|
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"})
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_str(value: Any) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_host(host: str | None) -> str:
|
||||||
|
"""Normalize a host identity for ownership matching (no credentials)."""
|
||||||
|
text = _norm_str(host).lower()
|
||||||
|
for prefix in ("https://", "http://"):
|
||||||
|
if text.startswith(prefix):
|
||||||
|
text = text[len(prefix) :]
|
||||||
|
# Drop path/query if a full URL slipped through.
|
||||||
|
text = text.split("/", 1)[0]
|
||||||
|
text = text.split("?", 1)[0]
|
||||||
|
return text.rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def ownership_category_for_role(role: str | None) -> str:
|
||||||
|
"""Map a role kind to a non-secret ownership category label."""
|
||||||
|
key = _norm_str(role).lower()
|
||||||
|
return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease")
|
||||||
|
|
||||||
|
|
||||||
|
def classify_branch_readback_http_status(
|
||||||
|
status_code: int | None,
|
||||||
|
*,
|
||||||
|
not_found_scope: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify a GET-branch HTTP status into a secret-free readback result.
|
||||||
|
|
||||||
|
R1: A bare/generic/repository/wrong-host 404 never yields
|
||||||
|
``verified_absent=True``. Only an authoritative *branch-scoped* not-found
|
||||||
|
(``not_found_scope='branch'``) may verify deletion.
|
||||||
|
"""
|
||||||
|
if status_code == 404:
|
||||||
|
scope = _norm_str(not_found_scope).lower() or NOT_FOUND_SCOPE_UNKNOWN
|
||||||
|
if scope == NOT_FOUND_SCOPE_BRANCH:
|
||||||
|
return {
|
||||||
|
"status": READBACK_NOT_FOUND,
|
||||||
|
"error_class": None,
|
||||||
|
"verified_absent": True,
|
||||||
|
"branch_present": False,
|
||||||
|
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
# repository / host / unknown / generic 404 — not verified absence
|
||||||
|
reason = {
|
||||||
|
NOT_FOUND_SCOPE_REPOSITORY: (
|
||||||
|
"post-delete readback 404 is repository-scoped, not branch absence"
|
||||||
|
),
|
||||||
|
NOT_FOUND_SCOPE_HOST: (
|
||||||
|
"post-delete readback 404 is host-scoped, not branch absence"
|
||||||
|
),
|
||||||
|
}.get(
|
||||||
|
scope,
|
||||||
|
"post-delete readback 404 is ambiguous (not branch-scoped); "
|
||||||
|
"cannot verify absence",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": READBACK_AMBIGUOUS_404,
|
||||||
|
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"not_found_scope": scope,
|
||||||
|
"reasons": [reason],
|
||||||
|
}
|
||||||
|
if status_code in (401, 407):
|
||||||
|
return {
|
||||||
|
"status": READBACK_AUTHENTICATION,
|
||||||
|
"error_class": ERROR_CLASS_AUTHENTICATION,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback authentication failed"],
|
||||||
|
}
|
||||||
|
if status_code == 403:
|
||||||
|
return {
|
||||||
|
"status": READBACK_AUTHORIZATION,
|
||||||
|
"error_class": ERROR_CLASS_AUTHORIZATION,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback authorization failed"],
|
||||||
|
}
|
||||||
|
if status_code is not None and 200 <= int(status_code) < 300:
|
||||||
|
return {
|
||||||
|
"status": READBACK_EXISTS,
|
||||||
|
"error_class": None,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": True,
|
||||||
|
"reasons": ["post-delete readback found branch still present"],
|
||||||
|
}
|
||||||
|
if status_code is not None and int(status_code) >= 500:
|
||||||
|
return {
|
||||||
|
"status": READBACK_TRANSPORT,
|
||||||
|
"error_class": ERROR_CLASS_TRANSPORT,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback transport/upstream failure"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": READBACK_UNEXPECTED,
|
||||||
|
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback returned an unexpected response"],
|
||||||
|
"http_status": status_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_http_status(exc: BaseException) -> int | None:
|
||||||
|
"""Extract an HTTP status code from an exception chain (secret-free)."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
current: BaseException | None = exc
|
||||||
|
while current is not None and id(current) not in seen:
|
||||||
|
seen.add(id(current))
|
||||||
|
for attr in ("code", "status", "status_code"):
|
||||||
|
value = getattr(current, attr, None)
|
||||||
|
if isinstance(value, int) and 100 <= value <= 599:
|
||||||
|
return value
|
||||||
|
text = str(current) if current is not None else ""
|
||||||
|
match = re.match(r"HTTP\s+(\d{3})\b", text)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
current = current.__cause__ or current.__context__
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_branch_readback_exception(
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
not_found_scope: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify a GET-branch exception without leaking credentials or bodies.
|
||||||
|
|
||||||
|
R1: substring ``404`` / ``not found`` alone never becomes verified_absent.
|
||||||
|
Callers must pass ``not_found_scope='branch'`` only after authoritative
|
||||||
|
proof that the repository/host is still reachable and the 404 is branch-level.
|
||||||
|
"""
|
||||||
|
status_code = _extract_http_status(exc)
|
||||||
|
if status_code is None:
|
||||||
|
lower = str(exc).lower() if exc is not None else ""
|
||||||
|
if any(
|
||||||
|
token in lower
|
||||||
|
for token in (
|
||||||
|
"timed out",
|
||||||
|
"timeout",
|
||||||
|
"connection",
|
||||||
|
"network",
|
||||||
|
"temporarily unavailable",
|
||||||
|
"name or service not known",
|
||||||
|
"nodename nor servname",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"status": READBACK_TRANSPORT,
|
||||||
|
"error_class": ERROR_CLASS_TRANSPORT,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": [
|
||||||
|
"post-delete branch readback transport/upstream failure"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if "unauthorized" in lower:
|
||||||
|
status_code = 401
|
||||||
|
elif "forbidden" in lower:
|
||||||
|
status_code = 403
|
||||||
|
elif "404" in lower or "not found" in lower:
|
||||||
|
# Ambiguous: do NOT treat as branch absence without scope proof.
|
||||||
|
status_code = 404
|
||||||
|
if not_found_scope is None:
|
||||||
|
not_found_scope = NOT_FOUND_SCOPE_UNKNOWN
|
||||||
|
|
||||||
|
if status_code is not None:
|
||||||
|
return classify_branch_readback_http_status(
|
||||||
|
status_code, not_found_scope=not_found_scope
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": READBACK_UNEXPECTED,
|
||||||
|
"error_class": ERROR_CLASS_UNEXPECTED,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": None,
|
||||||
|
"reasons": ["post-delete branch readback returned an unexpected response"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Decide whether DELETE success may be reported after branch readback.
|
||||||
|
|
||||||
|
Success requires authoritative branch-scoped not-found
|
||||||
|
(``verified_absent=True``). DELETE HTTP success alone is never enough.
|
||||||
|
Generic/repository/host 404 cannot verify absence (R1).
|
||||||
|
"""
|
||||||
|
payload = dict(readback or {})
|
||||||
|
status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED
|
||||||
|
# Only explicit verified_absent flag counts — never infer from status alone
|
||||||
|
# when status is a bare not_found without branch scope proof.
|
||||||
|
verified = bool(payload.get("verified_absent")) is True
|
||||||
|
if verified and status == READBACK_NOT_FOUND:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"success": True,
|
||||||
|
"verified_absent": True,
|
||||||
|
"readback": {
|
||||||
|
"status": READBACK_NOT_FOUND,
|
||||||
|
"verified_absent": True,
|
||||||
|
"branch_present": False,
|
||||||
|
"error_class": None,
|
||||||
|
"not_found_scope": NOT_FOUND_SCOPE_BRANCH,
|
||||||
|
},
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons = list(payload.get("reasons") or [])
|
||||||
|
if not reasons:
|
||||||
|
if status == READBACK_EXISTS:
|
||||||
|
reasons = ["post-delete readback found branch still present"]
|
||||||
|
elif status == READBACK_AUTHENTICATION:
|
||||||
|
reasons = ["post-delete branch readback authentication failed"]
|
||||||
|
elif status == READBACK_AUTHORIZATION:
|
||||||
|
reasons = ["post-delete branch readback authorization failed"]
|
||||||
|
elif status == READBACK_TRANSPORT:
|
||||||
|
reasons = ["post-delete branch readback transport/upstream failure"]
|
||||||
|
elif status == READBACK_AMBIGUOUS_404:
|
||||||
|
reasons = [
|
||||||
|
"post-delete readback 404 is not branch-scoped; "
|
||||||
|
"cannot verify absence"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
reasons = ["post-delete branch readback could not verify deletion"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"success": False,
|
||||||
|
"verified_absent": False,
|
||||||
|
"readback": {
|
||||||
|
"status": status,
|
||||||
|
"verified_absent": False,
|
||||||
|
"branch_present": payload.get("branch_present"),
|
||||||
|
"error_class": payload.get("error_class"),
|
||||||
|
"not_found_scope": payload.get("not_found_scope"),
|
||||||
|
},
|
||||||
|
"reasons": reasons,
|
||||||
|
"blocker_kind": "post_delete_readback_failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_result_envelope(
|
||||||
|
*,
|
||||||
|
success: bool,
|
||||||
|
performed: bool,
|
||||||
|
delete_acknowledged: bool,
|
||||||
|
verified_absent: bool,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""R2: consistent top-level cleanup result fields on every return path."""
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"success": bool(success),
|
||||||
|
"performed": bool(performed),
|
||||||
|
"delete_acknowledged": bool(delete_acknowledged),
|
||||||
|
"verified_absent": bool(verified_absent),
|
||||||
|
}
|
||||||
|
out.update(extra)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_matches(
|
||||||
|
record: dict[str, Any],
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Match ownership record to target remote/org/repo and normalized host."""
|
||||||
|
if (
|
||||||
|
_norm_str(record.get("remote")).lower() != _norm_str(remote).lower()
|
||||||
|
or _norm_str(record.get("org")).lower() != _norm_str(org).lower()
|
||||||
|
or _norm_str(record.get("repo")).lower() != _norm_str(repo).lower()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
expected_host = normalize_host(host)
|
||||||
|
record_host = normalize_host(record.get("host") or record.get("host_name"))
|
||||||
|
# When both sides declare a host, they must agree after normalization.
|
||||||
|
# A record host that disagrees with the expected host is out of scope.
|
||||||
|
# Legacy records without host still match when remote/org/repo agree.
|
||||||
|
if expected_host and record_host and expected_host != record_host:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_matches(record: dict[str, Any], branch: str) -> bool:
|
||||||
|
return _norm_str(record.get("branch")) == _norm_str(branch)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Classify one ownership record as blocking or non-blocking.
|
||||||
|
|
||||||
|
Distinguishes active ownership from expired/released/terminal/stale records.
|
||||||
|
Expired/stale records block unless reclaim_allowed is *explicitly* True
|
||||||
|
(O2: never treat missing/unknown reclaim as auto-allowed).
|
||||||
|
"""
|
||||||
|
status = _norm_str(record.get("status")).lower()
|
||||||
|
category = _norm_str(record.get("category")) or "unknown"
|
||||||
|
reclaim_allowed = record.get("reclaim_allowed")
|
||||||
|
|
||||||
|
if category == OWNERSHIP_CATEGORY_INVENTORY_ERROR:
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status or "unknown",
|
||||||
|
"category": category,
|
||||||
|
"reason": "ownership inventory failed closed",
|
||||||
|
}
|
||||||
|
|
||||||
|
if status in _TERMINAL_OWNERSHIP_STATUSES:
|
||||||
|
return {
|
||||||
|
"blocks": False,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": f"{category} ownership is terminal/released ({status})",
|
||||||
|
}
|
||||||
|
if status in _ACTIVE_OWNERSHIP_STATUSES:
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": f"active {category} ownership still uses the target branch",
|
||||||
|
}
|
||||||
|
if status in _EXPIRED_STATUSES or status in _STALE_STATUSES:
|
||||||
|
# O2: only explicit reclaim_allowed=True skips the block.
|
||||||
|
if reclaim_allowed is True:
|
||||||
|
return {
|
||||||
|
"blocks": False,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": (
|
||||||
|
f"{category} ownership is {status} and reclaimable; "
|
||||||
|
"does not block deletion"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status,
|
||||||
|
"category": category,
|
||||||
|
"reason": (
|
||||||
|
f"{status} {category} ownership still protects the target "
|
||||||
|
"branch (reclaim not proven; fail closed)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
# Unknown status → fail closed
|
||||||
|
return {
|
||||||
|
"blocks": True,
|
||||||
|
"status": status or "unknown",
|
||||||
|
"category": category,
|
||||||
|
"reason": (
|
||||||
|
f"unclassified {category} ownership status "
|
||||||
|
f"'{status or 'unknown'}'; fail closed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_active_branch_ownership(
|
||||||
|
*,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
branch: str,
|
||||||
|
host: str | None = None,
|
||||||
|
records: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Assess whether any active ownership still uses *branch* in *repo*.
|
||||||
|
|
||||||
|
Matching requires remote/org/repo/branch and, when provided, normalized
|
||||||
|
host identity. Other repositories, hosts, or branches never false-block.
|
||||||
|
"""
|
||||||
|
target_branch = _norm_str(branch)
|
||||||
|
expected_host = normalize_host(host)
|
||||||
|
considered: list[dict[str, Any]] = []
|
||||||
|
blocking: list[dict[str, Any]] = []
|
||||||
|
ignored: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for raw in records or []:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
if not _repo_matches(
|
||||||
|
raw, remote=remote, org=org, repo=repo, host=expected_host or None
|
||||||
|
):
|
||||||
|
ignored.append(
|
||||||
|
{
|
||||||
|
"category": _norm_str(raw.get("category")) or "unknown",
|
||||||
|
"reason": "different repository or host scope",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not _branch_matches(raw, target_branch):
|
||||||
|
ignored.append(
|
||||||
|
{
|
||||||
|
"category": _norm_str(raw.get("category")) or "unknown",
|
||||||
|
"reason": "different branch",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
activity = assess_ownership_record_activity(raw)
|
||||||
|
entry = {
|
||||||
|
"category": activity["category"],
|
||||||
|
"status": activity["status"],
|
||||||
|
"blocks": activity["blocks"],
|
||||||
|
"reason": activity["reason"],
|
||||||
|
}
|
||||||
|
considered.append(entry)
|
||||||
|
if activity["blocks"]:
|
||||||
|
blocking.append(entry)
|
||||||
|
|
||||||
|
block = bool(blocking)
|
||||||
|
categories = sorted({b["category"] for b in blocking})
|
||||||
|
reasons = [
|
||||||
|
(
|
||||||
|
"active ownership protects branch "
|
||||||
|
f"'{target_branch}': " + "; ".join(b["reason"] for b in blocking)
|
||||||
|
)
|
||||||
|
] if block else []
|
||||||
|
return {
|
||||||
|
"block": block,
|
||||||
|
"safe_to_delete": not block,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"host": expected_host or None,
|
||||||
|
"branch": target_branch,
|
||||||
|
"blocking_categories": categories,
|
||||||
|
"blocking": blocking,
|
||||||
|
"considered": considered,
|
||||||
|
"ignored_out_of_scope": ignored,
|
||||||
|
"reasons": reasons,
|
||||||
|
"blocker_kind": "active_branch_ownership" if block else None,
|
||||||
|
"recommended_action": "keep_remote_branch" if block else "delete_remote_branch",
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# ADR: Stable control runtime vs dev runtime (Gitea MCP)
|
||||||
|
|
||||||
|
- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag)
|
||||||
|
- **Date:** 2026-07-09
|
||||||
|
- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615)
|
||||||
|
- **Related:**
|
||||||
|
- [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health
|
||||||
|
- `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill)
|
||||||
|
- [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon
|
||||||
|
- [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes
|
||||||
|
- Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614)
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe:
|
||||||
|
|
||||||
|
- Mid-session identity/preflight resets
|
||||||
|
- Stale-runtime vs master parity failures
|
||||||
|
- IDE transport EOF / “tool not found” while code on disk has changed
|
||||||
|
- Accidental production mutations from experimental code
|
||||||
|
|
||||||
|
This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof.
|
||||||
|
|
||||||
|
## 2. Decision
|
||||||
|
|
||||||
|
### 2.1 Stable control runtime
|
||||||
|
|
||||||
|
The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**.
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate)
|
||||||
|
- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.)
|
||||||
|
- Holds production profile credentials via sanctioned keychain/env paths only
|
||||||
|
|
||||||
|
### 2.2 Dev / test runtime
|
||||||
|
|
||||||
|
MCP **server code** development and testing:
|
||||||
|
|
||||||
|
- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts)
|
||||||
|
- May use a **separate** dev/test MCP runtime/process when process-level testing is required
|
||||||
|
- **Must not** be used for real Gitea mutations on production issues/PRs
|
||||||
|
|
||||||
|
### 2.3 Forbidden actions (normal sessions)
|
||||||
|
|
||||||
|
Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**:
|
||||||
|
|
||||||
|
| Forbidden | Why |
|
||||||
|
|-----------|-----|
|
||||||
|
| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state |
|
||||||
|
| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow |
|
||||||
|
| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations |
|
||||||
|
| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users |
|
||||||
|
| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations |
|
||||||
|
| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity |
|
||||||
|
|
||||||
|
**LLM-allowed vs operator-owned (authoritative split):**
|
||||||
|
|
||||||
|
| Actor | May do | Must not do |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations |
|
||||||
|
| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — |
|
||||||
|
|
||||||
|
EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure.
|
||||||
|
|
||||||
|
This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits.
|
||||||
|
|
||||||
|
### 2.4 Promotion (operator / release-manager only)
|
||||||
|
|
||||||
|
Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step.
|
||||||
|
|
||||||
|
A promotion **must record** (issue comment, release note, or promotion ledger):
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **previous runtime SHA** | Commit previously loaded by stable runtime |
|
||||||
|
| **promoted runtime SHA** | Commit after promotion |
|
||||||
|
| **source branch/PR** | Where the change was reviewed |
|
||||||
|
| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) |
|
||||||
|
| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) |
|
||||||
|
| **identity/profile proof** | Expected profile(s) and username(s) after reload |
|
||||||
|
| **workspace/root proof** | Stable checkout path / root matches intended layout |
|
||||||
|
| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden |
|
||||||
|
| **rollback instructions** | How to restore previous SHA and re-verify health |
|
||||||
|
|
||||||
|
Suggested durable marker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## MCP STABLE RUNTIME PROMOTION (#615)
|
||||||
|
|
||||||
|
Status: COMPLETED | ROLLED_BACK | ABORTED
|
||||||
|
Previous-SHA: <full sha>
|
||||||
|
Promoted-SHA: <full sha>
|
||||||
|
Source-PR: <number>
|
||||||
|
Source-Branch: <name>
|
||||||
|
Reload-Method: <text>
|
||||||
|
Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK
|
||||||
|
Identity-Proof: profile=<name> user=<name>
|
||||||
|
Workspace-Proof: root=<path>
|
||||||
|
Mutation-Proof: allowed_ops include <…>; forbidden include <…>
|
||||||
|
Rollback: checkout <previous sha>; reload method <…>; re-run health/identity proofs
|
||||||
|
Operator: <username>
|
||||||
|
Timestamp: <ISO-8601>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Unhealthy stable runtime → stop work
|
||||||
|
|
||||||
|
If the stable MCP runtime is **unhealthy**, including any of:
|
||||||
|
|
||||||
|
- client-namespace probes fail
|
||||||
|
- wrong identity / wrong profile
|
||||||
|
- wrong workspace root
|
||||||
|
- missing mutation capability for the intended role
|
||||||
|
- persistent EOF after **client reconnect**
|
||||||
|
- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate)
|
||||||
|
|
||||||
|
then:
|
||||||
|
|
||||||
|
1. **Normal PR / review / merge / issue-mutation work must stop immediately.**
|
||||||
|
2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known.
|
||||||
|
3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass.
|
||||||
|
4. Resume only after:
|
||||||
|
- an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or**
|
||||||
|
- a **controlled** promotion/rollback completes with the §2.4 promotion record, **or**
|
||||||
|
- a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented,
|
||||||
|
- **and** the session re-verifies health (and master parity when applicable) before the next mutation.
|
||||||
|
|
||||||
|
### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion)
|
||||||
|
|
||||||
|
**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**.
|
||||||
|
|
||||||
|
**Sanctioned response (authoritative):**
|
||||||
|
|
||||||
|
| Step | Actor | Action |
|
||||||
|
|------|-------|--------|
|
||||||
|
| 1 | LLM session | Mutations stop immediately when the gate reports stale. |
|
||||||
|
| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. |
|
||||||
|
| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). |
|
||||||
|
| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). |
|
||||||
|
|
||||||
|
**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation).
|
||||||
|
|
||||||
|
**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split.
|
||||||
|
|
||||||
|
## 3. Relationship to other controls
|
||||||
|
|
||||||
|
| Doc / mechanism | Interaction |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart |
|
||||||
|
| EOF recovery | Reconnect only; no process kill |
|
||||||
|
| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import |
|
||||||
|
| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix |
|
||||||
|
| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes |
|
||||||
|
|
||||||
|
## 4. Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Predictable control plane for concurrent LLMs
|
||||||
|
- Clear operator-only promotion gate with rollback
|
||||||
|
- Aligns session behavior with health/EOF docs already landed
|
||||||
|
|
||||||
|
### Costs
|
||||||
|
|
||||||
|
- LLM sessions must wait when runtime is sick (no DIY restart)
|
||||||
|
- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Does not ban operator-supervised restarts during incidents
|
||||||
|
- Does not replace CI or code review for MCP changes
|
||||||
|
- Does not authorize editing stable checkout “because tests need a quick fix”
|
||||||
|
|
||||||
|
## 5. Implementation follow-ups (optional tooling)
|
||||||
|
|
||||||
|
These may land in later issues; the **policy binds sessions now**:
|
||||||
|
|
||||||
|
1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.”
|
||||||
|
2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata.
|
||||||
|
3. Promotion checklist script that emits the durable promotion marker fields.
|
||||||
|
|
||||||
|
**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling.
|
||||||
|
|
||||||
|
## 6. Acceptance for this ADR
|
||||||
|
|
||||||
|
1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`.
|
||||||
|
2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`).
|
||||||
|
3. Issue #615 references this path.
|
||||||
|
4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**.
|
||||||
|
5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating.
|
||||||
|
6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion.
|
||||||
|
|
||||||
|
## 7. Document history
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule |
|
||||||
|
| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers |
|
||||||
@@ -238,11 +238,43 @@ narrow operation set:
|
|||||||
- `gitea.issue.comment`
|
- `gitea.issue.comment`
|
||||||
- `gitea.issue.close`
|
- `gitea.issue.close`
|
||||||
- `gitea.pr.close`
|
- `gitea.pr.close`
|
||||||
|
- `gitea.branch.delete` (merged-branch cleanup only — see below)
|
||||||
|
|
||||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||||
`gitea.repo.commit`.
|
`gitea.repo.commit`.
|
||||||
|
|
||||||
|
### Merged-branch cleanup ownership (`gitea.branch.delete`)
|
||||||
|
|
||||||
|
The reconciler is the repository-supported owner of merged-PR source-branch
|
||||||
|
cleanup: `task_capability_map` maps `cleanup_merged_pr_branch` (and
|
||||||
|
`reconciliation_cleanup`) to role `reconciler` with permission
|
||||||
|
`gitea.branch.delete`. Post-merge branch lifecycle is reconciliation work —
|
||||||
|
it happens after the author, reviewer, and merger roles have completed, and
|
||||||
|
it must not be reachable from those roles.
|
||||||
|
|
||||||
|
Least-privilege constraints:
|
||||||
|
|
||||||
|
- `gitea.branch.delete` is granted **only** to reconciler profiles. Author,
|
||||||
|
reviewer, and merger profiles must never hold it; `gitea_delete_branch`
|
||||||
|
and `gitea_cleanup_merged_pr_branch` fail closed on any profile without
|
||||||
|
the permission.
|
||||||
|
- Even with the permission, reconciler deletion is only supported through the
|
||||||
|
guarded `gitea_cleanup_merged_pr_branch` path (#514 / #687): the PR must be
|
||||||
|
merged, the head an ancestor of the target, the branch not protected
|
||||||
|
(`master`/`main`/`dev`), the branch not a preservation/evidence ref (e.g.
|
||||||
|
`chore/issue-681-preserve-review-session-wip`), no open PR may still use the
|
||||||
|
head, and an explicit `CLEANUP MERGED PR <n> BRANCH <branch>` confirmation is
|
||||||
|
required. Raw `gitea_delete_branch` is **denied** to reconciler even when
|
||||||
|
`gitea.branch.delete` is present.
|
||||||
|
- Raw `git branch -d` / `git push --delete` cleanup remains blocked by
|
||||||
|
`branch_cleanup_guard` and the final-report validator regardless of
|
||||||
|
profile permissions.
|
||||||
|
- `gitea.branch.delete` has no short alias in `GITEA_OPERATION_ALIASES`;
|
||||||
|
write it fully qualified in `allowed_operations`. Migration must emit
|
||||||
|
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||||
|
`issue.close`, which the production normalizer rejects or drops).
|
||||||
|
|
||||||
Launch a static `gitea-reconciler` MCP namespace with
|
Launch a static `gitea-reconciler` MCP namespace with
|
||||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||||
@@ -251,6 +283,159 @@ Launch a static `gitea-reconciler` MCP namespace with
|
|||||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||||
heads are not already landed cannot be closed through this path.
|
heads are not already landed cannot be closed through this path.
|
||||||
|
|
||||||
|
### Operational runbook: grant reconciler `gitea.branch.delete` (#687)
|
||||||
|
|
||||||
|
Merging a code PR that updates `migrate_profiles.py` / `reconciler_profile.py`
|
||||||
|
**does not** change the live operator profile on disk. Apply the profile
|
||||||
|
change deliberately, then reconnect the client-managed namespace.
|
||||||
|
|
||||||
|
1. **Approved migration / profile-update command** (from the repo root, using
|
||||||
|
the project venv if present):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry-run first (default): validates v2 output, writes nothing
|
||||||
|
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json
|
||||||
|
|
||||||
|
# Apply: creates backup then writes migrated v2 config
|
||||||
|
python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json -w
|
||||||
|
# Optional explicit paths:
|
||||||
|
# python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json \
|
||||||
|
# -o ~/.config/gitea-tools/profiles.json \
|
||||||
|
# --backup ~/.config/gitea-tools/profiles.json.bak -w
|
||||||
|
```
|
||||||
|
|
||||||
|
If the live file is already v2, edit the reconciler identity’s
|
||||||
|
`allowed_operations` / `forbidden_operations` under
|
||||||
|
`environments.<env>.services.gitea.identities.reconciler` (or the
|
||||||
|
`prgs-reconciler` alias target) so allowed includes the canonical set
|
||||||
|
below — then re-validate with a load of the config (see step 3).
|
||||||
|
|
||||||
|
2. **Inspect the generated (or edited) profile** — confirm the reconciler
|
||||||
|
identity, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
cfg = json.loads(Path.home().joinpath(".config/gitea-tools/profiles.json").read_text())
|
||||||
|
# v2 environments shape:
|
||||||
|
ident = cfg["environments"]["prgs"]["services"]["gitea"]["identities"]["reconciler"]
|
||||||
|
print("role:", ident.get("role"))
|
||||||
|
print("allowed:", ident.get("allowed_operations"))
|
||||||
|
print("forbidden:", ident.get("forbidden_operations"))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Validate canonical operation names and least privilege**
|
||||||
|
|
||||||
|
Expected canonical **allowed** (defaults after migration):
|
||||||
|
|
||||||
|
- `gitea.read`
|
||||||
|
- `gitea.pr.close` (required)
|
||||||
|
- `gitea.pr.comment`
|
||||||
|
- `gitea.issue.comment`
|
||||||
|
- `gitea.issue.close`
|
||||||
|
- `gitea.branch.delete` (recommended; cleanup only)
|
||||||
|
|
||||||
|
Expected **forbidden** includes at least: `gitea.pr.approve`,
|
||||||
|
`gitea.pr.merge`, `gitea.pr.review`, `gitea.pr.create`,
|
||||||
|
`gitea.branch.push`, `gitea.repo.commit`.
|
||||||
|
|
||||||
|
No shorthand (`pr.close`, `issue.close`, `pr.comment`) may remain.
|
||||||
|
Validate with the production loader:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import gitea_config, reconciler_profile
|
||||||
|
from pathlib import Path
|
||||||
|
path = str(Path.home() / ".config/gitea-tools/profiles.json")
|
||||||
|
gitea_config.load_config(path) # fails closed on invalid config
|
||||||
|
# Or assess the reconciler lists directly after extracting them:
|
||||||
|
# print(reconciler_profile.assess_reconciler_profile(allowed, forbidden))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Merging PR #688 (or any code PR) does not update the live profile.**
|
||||||
|
Code changes only the migration helper, schema, docs, and tests. The
|
||||||
|
operator must still run `migrate_profiles.py -w` or an equivalent
|
||||||
|
authorized edit of `~/.config/gitea-tools/profiles.json`.
|
||||||
|
|
||||||
|
5. **Supported apply method:** `python3 migrate_profiles.py … -w` (backup
|
||||||
|
created automatically) **or** operator-authorized edit of the live
|
||||||
|
profiles file after backup. Unsupported: silent mtime tricks, manual
|
||||||
|
process kill to “reload”, or undocumented env overrides.
|
||||||
|
|
||||||
|
6. **Backup and validation:** `-w` copies the input to
|
||||||
|
`<input_path>.bak` (or `--backup PATH`) before writing. Re-run
|
||||||
|
`load_config` / `assess_reconciler_profile` after write. Keep the
|
||||||
|
`.bak` until live whoami/capability checks pass.
|
||||||
|
|
||||||
|
7. **Client-managed namespace reconnect/reload:** reconnect or reload the
|
||||||
|
IDE MCP client so `gitea-reconciler` restarts from current `master` and
|
||||||
|
the updated `GITEA_MCP_PROFILE=prgs-reconciler` config. Do not hand-launch
|
||||||
|
`mcp_server.py` / `gitea_mcp_server.py` with ad hoc `GITEA_*` env
|
||||||
|
(see #686 / #630).
|
||||||
|
|
||||||
|
8. **Live reverification** (through the client-managed `gitea-reconciler`
|
||||||
|
namespace only):
|
||||||
|
|
||||||
|
- `gitea_whoami` → identity + profile `prgs-reconciler`
|
||||||
|
- `gitea_assess_master_parity` → `stale=false`, `restart_required=false`
|
||||||
|
- `gitea_resolve_task_capability(task="cleanup_merged_pr_branch")` →
|
||||||
|
`allowed_in_current_session=true` only when permission and role match
|
||||||
|
- `gitea_resolve_task_capability(task="delete_branch")` →
|
||||||
|
**not** allowed for reconciler (role denial must be enforced)
|
||||||
|
|
||||||
|
9. **Guarded cleanup usage** (example for a merged PR whose source branch
|
||||||
|
remains on the remote):
|
||||||
|
|
||||||
|
```text
|
||||||
|
gitea_cleanup_merged_pr_branch(
|
||||||
|
pr_number=<N>,
|
||||||
|
branch=<exact PR head branch>,
|
||||||
|
confirmation="CLEANUP MERGED PR <N> BRANCH <exact PR head branch>",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path="<path under branches/>",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool refuses unmerged PRs, protected branches, preservation/evidence
|
||||||
|
branches, open-PR heads, mismatched branch names, and wrong confirmation.
|
||||||
|
|
||||||
|
10. **Prohibitions**
|
||||||
|
|
||||||
|
- No raw `git push --delete`, `git branch -d` / `-D`, or delete refspecs
|
||||||
|
- No arbitrary `gitea_delete_branch` from reconciler
|
||||||
|
- No unsupported profile switching mid-run without full re-preflight
|
||||||
|
- No ad hoc hand-edits of live profiles **unless** operator-authorized,
|
||||||
|
backed up, and revalidated as above
|
||||||
|
|
||||||
|
Canonical migrated reconciler example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.branch.delete"
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Identity and fail-closed rules
|
## Identity and fail-closed rules
|
||||||
|
|
||||||
Before **any** mutating action, a workflow must know both:
|
Before **any** mutating action, a workflow must know both:
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
|||||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||||
review or author workflows.
|
review or author workflows.
|
||||||
|
|
||||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Setup runbook — interactive menu
|
## Setup runbook — interactive menu
|
||||||
|
|
||||||
@@ -1200,10 +1200,13 @@ When a mutation blocks on workspace binding:
|
|||||||
|
|
||||||
1. Read the error — it names the **resolved workspace path**, **role
|
1. Read the error — it names the **resolved workspace path**, **role
|
||||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when
|
||||||
workspace (or set the role-specific env var before launch).
|
the active `branches/` worktree differs from the MCP process root; **client
|
||||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
reconnect** after transport EOF only (no process kill).
|
||||||
branches/ worktree differs from the MCP process root.
|
3. **Operator-owned:** if the wrong namespace process was launched, or a role-
|
||||||
|
specific `GITEA_*_WORKTREE` must be set at process start, an **operator**
|
||||||
|
reloads/relaunches the correct static namespace MCP. LLM sessions must not
|
||||||
|
kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||||
own namespace — that destroys another agent's WIP.
|
own namespace — that destroys another agent's WIP.
|
||||||
|
|
||||||
@@ -1213,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||||
- Quote the resolved workspace path and binding source from the error.
|
- Quote the resolved workspace path and binding source from the error.
|
||||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
- Name the safe next action: pass `worktree_path` if that unblocks the tool, or
|
||||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
request an **operator** reload of the correct namespace MCP / env binding.
|
||||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
- Explicitly note that foreign worktrees must not be cleaned to unblock, and
|
||||||
|
that the LLM must not self-restart the MCP process.
|
||||||
|
|
||||||
## Safety notes
|
## Safety notes
|
||||||
|
|
||||||
@@ -1226,6 +1230,7 @@ When posting a Canonical Thread Handoff after a binding blocker:
|
|||||||
|
|
||||||
## Related documents
|
## Related documents
|
||||||
|
|
||||||
|
- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615).
|
||||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Self-hosted Sentry observability for the Gitea MCP server (#606)
|
||||||
|
|
||||||
|
Optional, **off-by-default** instrumentation that reports MCP runtime errors,
|
||||||
|
fail-closed workflow blockers, lease / terminal-lock / stale-runtime
|
||||||
|
collisions, and recurring watchdog check-ins to a **self-hosted** Sentry at
|
||||||
|
`https://sentry.prgs.cc/`.
|
||||||
|
|
||||||
|
> **Gitea remains the source of truth.** Sentry is observe-only. It never
|
||||||
|
> approves, merges, closes, or otherwise mutates Gitea workflow state, and it
|
||||||
|
> never bypasses leases, #332, workflow roles, or the MCP gates. Sentry alerts
|
||||||
|
> may only feed the *sanctioned* Gitea issue/comment path via the #612 incident
|
||||||
|
> bridge — never a direct write.
|
||||||
|
|
||||||
|
Implemented by [`sentry_observability.py`](../../sentry_observability.py).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Create the Sentry project
|
||||||
|
|
||||||
|
1. Sign in to the self-hosted Sentry at **`https://sentry.prgs.cc/`** (this is
|
||||||
|
**not** Sentry Cloud — do not use `*.ingest.sentry.io`).
|
||||||
|
2. Create a new **Python** project named **`gitea-tools-mcp`**.
|
||||||
|
3. Open **Settings → Projects → gitea-tools-mcp → Client Keys (DSN)** and copy
|
||||||
|
the DSN. It looks like `https://<publickey>@sentry.prgs.cc/<project-id>`.
|
||||||
|
4. **Never commit the DSN.** It is a runtime secret supplied via env var only.
|
||||||
|
|
||||||
|
## 2. Configure the environment
|
||||||
|
|
||||||
|
All configuration is env-var driven (see [`.env.example`](../../.env.example)):
|
||||||
|
|
||||||
|
| Variable | Purpose | Default |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `MCP_SENTRY_ENABLED` | Master gate (`1/true/yes/on`). Required. | off |
|
||||||
|
| `SENTRY_DSN` | Self-hosted DSN. Required. | *(empty)* |
|
||||||
|
| `SENTRY_ENVIRONMENT` | `local` / `dev` / `prod` tag. | `development` |
|
||||||
|
| `SENTRY_RELEASE` | Release id (git SHA or version). | *(none)* |
|
||||||
|
| `MCP_SENTRY_TRACES_SAMPLE_RATE` | Perf-trace sample rate `0.0–1.0` (clamped). | `0.0` |
|
||||||
|
| `MCP_SENTRY_ENABLE_LOGS` | Forward Python logs as structured logs. | off |
|
||||||
|
|
||||||
|
**The feature stays completely off unless `MCP_SENTRY_ENABLED` is truthy *and*
|
||||||
|
`SENTRY_DSN` is non-empty.** With either missing, `init_sentry()` is a no-op,
|
||||||
|
the SDK is never initialised, and no events are sent — existing tool behaviour
|
||||||
|
and API-call patterns are unchanged.
|
||||||
|
|
||||||
|
### Per-environment examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# local (quiet: capture errors/blockers, no traces)
|
||||||
|
export MCP_SENTRY_ENABLED=1
|
||||||
|
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||||
|
export SENTRY_ENVIRONMENT=local
|
||||||
|
|
||||||
|
# dev (light tracing + logs)
|
||||||
|
export MCP_SENTRY_ENABLED=1
|
||||||
|
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||||
|
export SENTRY_ENVIRONMENT=dev
|
||||||
|
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.2
|
||||||
|
export MCP_SENTRY_ENABLE_LOGS=1
|
||||||
|
|
||||||
|
# prod (errors/blockers + low-rate tracing, release-tagged)
|
||||||
|
export MCP_SENTRY_ENABLED=1
|
||||||
|
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
|
||||||
|
export SENTRY_ENVIRONMENT=prod
|
||||||
|
export SENTRY_RELEASE="$(git rev-parse --short HEAD)"
|
||||||
|
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.05
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional SDK is pinned in [`requirements.txt`](../../requirements.txt)
|
||||||
|
(`sentry-sdk==2.20.0`). It is imported lazily: if the package is absent, the
|
||||||
|
module still imports and every entry point is a safe no-op.
|
||||||
|
|
||||||
|
## 3. What is instrumented
|
||||||
|
|
||||||
|
| Signal | Where | Notes |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| Startup init | `gitea_mcp_server.py` `__main__`, before `mcp.run` | Prints a redaction-safe status line to stderr. |
|
||||||
|
| Failing mutations (exceptions) | `_audited(...)` context manager | `capture_exception` with scrubbed tags. |
|
||||||
|
| Fail-closed blockers / failed mutations | `_audit_pr_result(...)` (BLOCKED/FAILED) | Structured `capture_workflow_blocker` event incl. the canonical next action when available (criterion 7). |
|
||||||
|
| Allocator watchdog check-ins | `gitea_allocate_next_work` tool | `allocator_health`, `stale_lease_scan`, `terminal_lock_scan`. |
|
||||||
|
| Namespace-health check-in | `gitea_assess_mcp_namespace_health` tool | `namespace_health`. |
|
||||||
|
|
||||||
|
All capture paths are **best-effort / fail open**: a Sentry outage or capture
|
||||||
|
error never breaks an MCP tool success path.
|
||||||
|
|
||||||
|
## 4. Cron / watchdog monitors
|
||||||
|
|
||||||
|
`sentry_observability.MONITOR_SLUGS` defines stable check-in slugs:
|
||||||
|
|
||||||
|
| Registry key | Sentry monitor slug | Wired at |
|
||||||
|
|--------------|--------------------|----------|
|
||||||
|
| `stale_lease_scan` | `gitea-mcp-stale-lease-scan` | allocator run (global lease expiry) |
|
||||||
|
| `terminal_lock_scan` | `gitea-mcp-terminal-lock-scan` | allocator run (terminal-lock lookup) |
|
||||||
|
| `allocator_health` | `gitea-mcp-allocator-health` | allocator run |
|
||||||
|
| `namespace_health` | `gitea-mcp-namespace-health` | namespace-health probe |
|
||||||
|
| `dashboard_freshness` | `gitea-mcp-dashboard-freshness` | call `monitor_checkin("dashboard_freshness", ...)` from the dashboard refresh job (#605) |
|
||||||
|
| `reconciler_cleanup` | `gitea-mcp-reconciler-cleanup` | call `monitor_checkin("reconciler_cleanup", ...)` from the reconciler cleanup entrypoint |
|
||||||
|
|
||||||
|
Create matching Cron monitors in Sentry with those slugs. Emit an
|
||||||
|
`in_progress` check-in at job start and `ok`/`error` at completion via
|
||||||
|
`sentry_observability.monitor_checkin(slug_key, status)`.
|
||||||
|
|
||||||
|
## 5. Redaction guarantees (fail closed)
|
||||||
|
|
||||||
|
Redaction fails *closed*: if a field cannot be proven safe it is dropped rather
|
||||||
|
than sent. The `before_send` (and `before_send_log`) hook `scrub_event`
|
||||||
|
recursively redacts every outgoing event; on any error it drops the event
|
||||||
|
entirely. Guarantees, proven by `tests/test_sentry_observability.py`:
|
||||||
|
|
||||||
|
- **No** tokens, passwords, keychain IDs, DSNs, cookies, or `user:pass@host`.
|
||||||
|
- **No** raw session-state or full prompt/comment bodies — `session_id` is only
|
||||||
|
ever surfaced as a 12-char `session_id_hash`.
|
||||||
|
- **No** private config contents or raw credential headers.
|
||||||
|
- **No** full local filesystem paths — a worktree path collapses to a coarse
|
||||||
|
`worktree_category` (`author` / `reviewer` / `merger` / `reconciler` /
|
||||||
|
`branches` / `root` / `other`).
|
||||||
|
- Only the allowlisted tag keys in `ALLOWED_TAG_KEYS` are ever attached.
|
||||||
|
|
||||||
|
## 6. Coexistence with GlitchTip / the #612 incident bridge
|
||||||
|
|
||||||
|
This is the **outbound** path (MCP → Sentry SDK). It complements — it does not
|
||||||
|
replace — the **inbound** [`incident_bridge.py`](../../incident_bridge.py)
|
||||||
|
(#612), which turns Sentry/GlitchTip *observations* into durable Gitea issues
|
||||||
|
and `incident_links` rows.
|
||||||
|
|
||||||
|
- Prefer **one** observability path per environment. Point the MCP server's
|
||||||
|
`SENTRY_DSN` at the same self-hosted `gitea-tools-mcp` project that the #612
|
||||||
|
bridge reconciles from, so an MCP-reported error and its Gitea issue line up.
|
||||||
|
- GlitchTip is Sentry-protocol compatible; if an existing GlitchTip DSN is in
|
||||||
|
use, either migrate it to `https://sentry.prgs.cc/` or document the split
|
||||||
|
(MCP → Sentry, legacy → GlitchTip) explicitly for operators.
|
||||||
|
- The bridge remains the **only** sanctioned route from an alert back into
|
||||||
|
Gitea workflow state.
|
||||||
|
|
||||||
|
## 7. Non-goals
|
||||||
|
|
||||||
|
- Sentry must **not** become the workflow source of truth.
|
||||||
|
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
|
||||||
|
- Sentry must **not** bypass leases, #332, workflow roles, or the MCP gates.
|
||||||
@@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server
|
|||||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||||
|
7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615).
|
||||||
|
|
||||||
## Supported Gitea instances
|
## Supported Gitea instances
|
||||||
|
|
||||||
@@ -30,3 +31,4 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool
|
|||||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||||
|
5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)).
|
||||||
@@ -12,6 +12,17 @@
|
|||||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||||
|
|
||||||
|
## Stable MCP control runtime (#615)
|
||||||
|
|
||||||
|
Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md).
|
||||||
|
|
||||||
|
| Situation | Who acts | What to do |
|
||||||
|
|-----------|----------|------------|
|
||||||
|
| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs |
|
||||||
|
| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) |
|
||||||
|
| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified |
|
||||||
|
| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record |
|
||||||
|
|
||||||
## Gitea Wiki sync
|
## Gitea Wiki sync
|
||||||
|
|
||||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||||
|
|||||||
+207
-19
@@ -243,6 +243,192 @@ def _redact(text):
|
|||||||
return str(text)
|
return str(text)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Classified client failures (#699) ─────────────────────────────────────────
|
||||||
|
# Subclasses of RuntimeError preserve existing ``except RuntimeError`` call
|
||||||
|
# sites. Exception *messages* are fixed constants only — HTTP response bodies,
|
||||||
|
# Keychain material, and arbitrary exception text are never stored on the
|
||||||
|
# exception or re-emitted to tool results / daemon logs.
|
||||||
|
|
||||||
|
|
||||||
|
# Fixed messages (must match mcp_tool_error_boundary.FIXED_MESSAGES keys used here).
|
||||||
|
_MSG_AUTH_INVALID = "Gitea authentication failed: invalid or revoked credentials"
|
||||||
|
_MSG_AUTH_FAILED = "Gitea authentication failed"
|
||||||
|
_MSG_AUTHZ_SCOPE = "Gitea authorization failed: insufficient token scope"
|
||||||
|
_MSG_AUTHZ_DENIED = "Gitea authorization failed: access denied"
|
||||||
|
_MSG_NETWORK = "Network error contacting Gitea"
|
||||||
|
_MSG_CONFIG = "Gitea configuration or credential resolution failed"
|
||||||
|
_MSG_UPSTREAM = "Gitea upstream unavailable"
|
||||||
|
_MSG_HTTP = "Gitea HTTP request failed"
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClientError(RuntimeError):
|
||||||
|
"""Base for known Gitea client failures with stable reason_code metadata."""
|
||||||
|
|
||||||
|
reason_code = "client_error"
|
||||||
|
error_class = "client"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
if reason_code is not None:
|
||||||
|
self.reason_code = reason_code
|
||||||
|
if http_status is not None:
|
||||||
|
self.http_status = http_status
|
||||||
|
# Message is always a fixed constant; callers cannot inject bodies.
|
||||||
|
fixed = message if message is not None else _MSG_HTTP
|
||||||
|
super().__init__(fixed)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaAuthError(GiteaClientError):
|
||||||
|
"""Authentication failure (invalid/revoked credentials → typically HTTP 401)."""
|
||||||
|
|
||||||
|
reason_code = "auth_invalid_token"
|
||||||
|
error_class = "authentication"
|
||||||
|
http_status = 401
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else _MSG_AUTH_INVALID,
|
||||||
|
reason_code=reason_code or "auth_invalid_token",
|
||||||
|
http_status=http_status if http_status is not None else 401,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaAuthzError(GiteaClientError):
|
||||||
|
"""Authorization failure (HTTP 403 — scope deficiency or access denied)."""
|
||||||
|
|
||||||
|
reason_code = "authz_denied"
|
||||||
|
error_class = "authorization"
|
||||||
|
http_status = 403
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
code = reason_code or "authz_denied"
|
||||||
|
if code == "authz_insufficient_scope":
|
||||||
|
fixed = _MSG_AUTHZ_SCOPE
|
||||||
|
else:
|
||||||
|
fixed = _MSG_AUTHZ_DENIED
|
||||||
|
code = "authz_denied"
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else fixed,
|
||||||
|
reason_code=code,
|
||||||
|
http_status=http_status if http_status is not None else 403,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaNetworkError(GiteaClientError):
|
||||||
|
"""Transport / DNS / timeout failure contacting Gitea."""
|
||||||
|
|
||||||
|
reason_code = "network_error"
|
||||||
|
error_class = "network"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else _MSG_NETWORK,
|
||||||
|
reason_code=reason_code or "network_error",
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaConfigError(GiteaClientError):
|
||||||
|
"""Local configuration / credential resolution failure (not HTTP auth)."""
|
||||||
|
|
||||||
|
reason_code = "config_error"
|
||||||
|
error_class = "configuration"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else _MSG_CONFIG,
|
||||||
|
reason_code=reason_code or "config_error",
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaHttpError(GiteaClientError):
|
||||||
|
"""Non-auth HTTP failure with fixed message (no response body)."""
|
||||||
|
|
||||||
|
reason_code = "http_error"
|
||||||
|
error_class = "client"
|
||||||
|
http_status = None
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, reason_code=None, http_status=None):
|
||||||
|
code = reason_code or "http_error"
|
||||||
|
if code == "upstream_unavailable":
|
||||||
|
fixed = _MSG_UPSTREAM
|
||||||
|
else:
|
||||||
|
fixed = _MSG_HTTP
|
||||||
|
code = "http_error"
|
||||||
|
super().__init__(
|
||||||
|
message if message is not None else fixed,
|
||||||
|
reason_code=code,
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_insufficient_scope(detail: str) -> bool:
|
||||||
|
"""Internal: inspect redacted body *only* to refine 403 reason_code.
|
||||||
|
|
||||||
|
The body is never stored on the exception or returned to callers.
|
||||||
|
"""
|
||||||
|
lower = (detail or "").lower()
|
||||||
|
markers = (
|
||||||
|
"insufficient scope",
|
||||||
|
"required scope",
|
||||||
|
"does not have at least one of required scope",
|
||||||
|
"token does not have",
|
||||||
|
"missing scope",
|
||||||
|
"scope(s)",
|
||||||
|
)
|
||||||
|
return any(m in lower for m in markers)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_http_status(code: int, *, body_hint: str = "") -> tuple[type, str, int]:
|
||||||
|
"""Central HTTP status → (exception_class, reason_code, http_status).
|
||||||
|
|
||||||
|
Every HTTP 403 becomes authorization-class. Body text is used only as a
|
||||||
|
local hint for scope vs denied reason_code and is never returned.
|
||||||
|
"""
|
||||||
|
if code == 401:
|
||||||
|
return (GiteaAuthError, "auth_invalid_token", 401)
|
||||||
|
if code == 403:
|
||||||
|
if _looks_like_insufficient_scope(body_hint or ""):
|
||||||
|
return (GiteaAuthzError, "authz_insufficient_scope", 403)
|
||||||
|
return (GiteaAuthzError, "authz_denied", 403)
|
||||||
|
if code in (502, 503, 504):
|
||||||
|
return (GiteaHttpError, "upstream_unavailable", code)
|
||||||
|
return (GiteaHttpError, "http_error", code)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_for_http_status(code: int, body: str = "") -> None:
|
||||||
|
"""Raise a typed client error for *code* without embedding *body*.
|
||||||
|
|
||||||
|
*body* may be inspected only to choose scope vs denied for 403; it is
|
||||||
|
never placed on the exception message.
|
||||||
|
"""
|
||||||
|
# Redact before any inspection; discard after classification.
|
||||||
|
try:
|
||||||
|
hint = _redact(body or "").strip()
|
||||||
|
except Exception:
|
||||||
|
hint = ""
|
||||||
|
exc_cls, reason, status = classify_http_status(code, body_hint=hint)
|
||||||
|
# Explicitly construct without passing body/hint into message.
|
||||||
|
if exc_cls is GiteaAuthError:
|
||||||
|
raise GiteaAuthError(reason_code=reason, http_status=status)
|
||||||
|
if exc_cls is GiteaAuthzError:
|
||||||
|
raise GiteaAuthzError(reason_code=reason, http_status=status)
|
||||||
|
if reason == "upstream_unavailable":
|
||||||
|
raise GiteaHttpError(
|
||||||
|
reason_code="upstream_unavailable",
|
||||||
|
http_status=status,
|
||||||
|
)
|
||||||
|
raise GiteaHttpError(reason_code="http_error", http_status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_http_error(code: int, detail: str = "") -> None:
|
||||||
|
"""Backward-compatible alias — *detail* is never embedded in the error."""
|
||||||
|
raise_for_http_status(code, detail)
|
||||||
|
|
||||||
|
|
||||||
def _add_query(url, **params):
|
def _add_query(url, **params):
|
||||||
"""Return *url* with the given query parameters added or overridden.
|
"""Return *url* with the given query parameters added or overridden.
|
||||||
|
|
||||||
@@ -315,23 +501,24 @@ def api_request(method, url, auth_header, payload=None, *,
|
|||||||
"""Make an authenticated JSON request to the Gitea API.
|
"""Make an authenticated JSON request to the Gitea API.
|
||||||
|
|
||||||
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
||||||
``RuntimeError`` on failure.
|
a classified client error on failure.
|
||||||
|
|
||||||
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
||||||
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
||||||
using capped jittered exponential backoff. Successful responses are
|
using capped jittered exponential backoff. Successful responses are
|
||||||
unchanged.
|
unchanged.
|
||||||
|
|
||||||
All failures are converted to a ``RuntimeError`` with a clear, secret
|
Failures raise typed exceptions with **fixed messages only** (#699). HTTP
|
||||||
-redacted message (no raw stack traces or credential material):
|
response bodies are read solely for local 403 reason refinement and are
|
||||||
|
never stored on exceptions or returned to callers:
|
||||||
|
|
||||||
- Non-429 HTTP errors surface the status code and a redacted response body.
|
- HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``)
|
||||||
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
|
- HTTP 403 → :class:`GiteaAuthzError` (scope or denied)
|
||||||
message.
|
- 502/503/504 → :class:`GiteaHttpError` (``upstream_unavailable``)
|
||||||
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
|
- Other non-429 HTTP → :class:`GiteaHttpError` (``http_error``)
|
||||||
a generic "network error contacting Gitea" message.
|
- Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError`
|
||||||
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
|
- Malformed success JSON → plain ``RuntimeError`` (programming/protocol;
|
||||||
message rather than a raw decode error.
|
not reclassified as authentication)
|
||||||
|
|
||||||
The ``*_func`` parameters and ``timeout`` are injection points for
|
The ``*_func`` parameters and ``timeout`` are injection points for
|
||||||
deterministic testing.
|
deterministic testing.
|
||||||
@@ -369,22 +556,23 @@ def api_request(method, url, auth_header, payload=None, *,
|
|||||||
error_body = e.read().decode("utf-8", errors="replace")
|
error_body = e.read().decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception:
|
||||||
error_body = ""
|
error_body = ""
|
||||||
detail = _redact(error_body).strip()
|
# Classify from status (+ local body hint). Body is not embedded.
|
||||||
if e.code in (502, 503, 504):
|
try:
|
||||||
msg = f"HTTP {e.code}: Gitea upstream unavailable"
|
raise_for_http_status(e.code, error_body)
|
||||||
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
|
except GiteaClientError:
|
||||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
raise
|
||||||
|
# Defensive: raise_for_http_status always raises.
|
||||||
|
raise GiteaHttpError(http_status=e.code) from e # pragma: no cover
|
||||||
except (urllib.error.URLError, TimeoutError) as e:
|
except (urllib.error.URLError, TimeoutError) as e:
|
||||||
reason = getattr(e, "reason", e)
|
# Fixed message only — do not embed URLError reason (may leak paths).
|
||||||
raise RuntimeError(
|
raise GiteaNetworkError(reason_code="network_error") from e
|
||||||
f"network error contacting Gitea: {_redact(reason)}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
if not body:
|
if not body:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return json.loads(body)
|
return json.loads(body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
# Programming/protocol failure — not authentication.
|
||||||
raise RuntimeError("malformed JSON response from Gitea") from e
|
raise RuntimeError("malformed JSON response from Gitea") from e
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4206
-161
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+408
-8
@@ -36,7 +36,43 @@ 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.
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
||||||
|
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery"
|
||||||
|
# #709: truthful record when historical terminal evidence is irrecoverably gone.
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
|
||||||
|
# #709 F1: server-side non-forgeable authorization artifact for recovery.
|
||||||
|
KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization"
|
||||||
|
|
||||||
|
# Kinds that must survive the default session-state TTL (forensic / recovery).
|
||||||
|
#
|
||||||
|
# KIND_DECISION_LOCK is recovery-critical (#720): terminal review provenance is
|
||||||
|
# not disposable cache. A generic four-hour TTL must not drop old-head evidence
|
||||||
|
# or make ``fresh_review_on_current_head_allowed`` unreachable. Same-head / same-
|
||||||
|
# run #332 protections still apply once the ledger is loadable. Other session
|
||||||
|
# kinds (workflow load, drafts, etc.) remain TTL-bound.
|
||||||
|
RECOVERY_CRITICAL_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
KIND_DECISION_LOCK,
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE,
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY,
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||||
|
KIND_IRRECOVERABLE_PROVENANCE_AUTH,
|
||||||
|
# #702 crash-orphan evidence (must outlive TTL; F4)
|
||||||
|
KIND_REVIEWER_SESSION_LEASE,
|
||||||
|
KIND_STALE_BINDING_RECOVERY,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||||
@@ -114,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.
|
||||||
|
|
||||||
@@ -121,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(
|
||||||
@@ -142,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(
|
||||||
@@ -150,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")
|
||||||
|
|
||||||
@@ -228,6 +270,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def is_recovery_critical_record(record: dict[str, Any] | None, kind: str | None = None) -> bool:
|
||||||
|
"""True when a durable record must outlive the generic session-state TTL."""
|
||||||
|
if not record and not kind:
|
||||||
|
return False
|
||||||
|
record_kind = ((record or {}).get("kind") or kind or "").strip()
|
||||||
|
if record_kind in RECOVERY_CRITICAL_KINDS:
|
||||||
|
return True
|
||||||
|
return bool((record or {}).get("recovery_critical"))
|
||||||
|
|
||||||
|
|
||||||
def identity_match_reasons(
|
def identity_match_reasons(
|
||||||
record: dict[str, Any] | None,
|
record: dict[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
@@ -270,7 +322,9 @@ def identity_match_reasons(
|
|||||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||||
else:
|
else:
|
||||||
age = _now_utc() - recorded_at
|
age = _now_utc() - recorded_at
|
||||||
if age > timedelta(hours=ttl_hours()):
|
kind = (record.get("kind") or "").strip()
|
||||||
|
ttl_exempt = is_recovery_critical_record(record, kind=kind)
|
||||||
|
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||||
)
|
)
|
||||||
@@ -279,6 +333,113 @@ def identity_match_reasons(
|
|||||||
return reasons
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_state_envelope(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
profile_identity: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read-only disk inspection for assessment when TTL would otherwise hide state (#720).
|
||||||
|
|
||||||
|
Does **not** apply identity/TTL rejection to the returned presence flags.
|
||||||
|
Callers use this to distinguish:
|
||||||
|
* no file on disk
|
||||||
|
* file present but TTL would reject a non-critical kind
|
||||||
|
* recovery-critical ledger (e.g. KIND_DECISION_LOCK) still loadable
|
||||||
|
Never mutates files. Never returns secrets.
|
||||||
|
"""
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=state_dir,
|
||||||
|
)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"kind": kind,
|
||||||
|
"profile_identity": profile,
|
||||||
|
"path_basename": os.path.basename(path),
|
||||||
|
"on_disk": False,
|
||||||
|
"has_payload": False,
|
||||||
|
"recorded_at": None,
|
||||||
|
"updated_at": None,
|
||||||
|
"age_hours": None,
|
||||||
|
"ttl_hours": ttl_hours(),
|
||||||
|
"age_exceeds_default_ttl": False,
|
||||||
|
"recovery_critical": kind in RECOVERY_CRITICAL_KINDS,
|
||||||
|
"ttl_exempt": kind in RECOVERY_CRITICAL_KINDS,
|
||||||
|
"would_ttl_reject": False,
|
||||||
|
"identity_reasons": [],
|
||||||
|
"summary": "no session-state file on disk",
|
||||||
|
}
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return result
|
||||||
|
result["on_disk"] = True
|
||||||
|
envelope = _read_json(path)
|
||||||
|
if not envelope:
|
||||||
|
result["summary"] = "session-state file present but unreadable or empty"
|
||||||
|
return result
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
|
||||||
|
result["has_payload"] = isinstance(payload, dict)
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"profile_identity",
|
||||||
|
"session_profile_lock",
|
||||||
|
"recorded_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer_pid",
|
||||||
|
"recovery_critical",
|
||||||
|
):
|
||||||
|
if key in envelope and key not in merged:
|
||||||
|
merged[key] = envelope[key]
|
||||||
|
if not merged.get("kind"):
|
||||||
|
merged["kind"] = kind
|
||||||
|
recorded_at = _parse_iso(merged.get("recorded_at") or merged.get("updated_at"))
|
||||||
|
result["recorded_at"] = merged.get("recorded_at") or merged.get("updated_at")
|
||||||
|
result["updated_at"] = merged.get("updated_at") or merged.get("recorded_at")
|
||||||
|
if recorded_at is not None:
|
||||||
|
age = _now_utc() - recorded_at
|
||||||
|
age_hours = age.total_seconds() / 3600.0
|
||||||
|
result["age_hours"] = age_hours
|
||||||
|
result["age_exceeds_default_ttl"] = age > timedelta(hours=ttl_hours())
|
||||||
|
ttl_exempt = is_recovery_critical_record(merged, kind=kind)
|
||||||
|
result["recovery_critical"] = ttl_exempt
|
||||||
|
result["ttl_exempt"] = ttl_exempt
|
||||||
|
identity_reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
)
|
||||||
|
result["identity_reasons"] = list(identity_reasons)
|
||||||
|
result["would_ttl_reject"] = any("expired" in r for r in identity_reasons)
|
||||||
|
if result["has_payload"] and ttl_exempt:
|
||||||
|
result["summary"] = (
|
||||||
|
"recovery-critical session-state present on disk and TTL-exempt; "
|
||||||
|
"load via load_state for full payload"
|
||||||
|
)
|
||||||
|
elif result["has_payload"] and result["would_ttl_reject"]:
|
||||||
|
result["summary"] = (
|
||||||
|
"session-state file present on disk but generic TTL would reject load "
|
||||||
|
f"(age_hours={result.get('age_hours')!r}, ttl={ttl_hours():g}h)"
|
||||||
|
)
|
||||||
|
elif result["has_payload"]:
|
||||||
|
result["summary"] = "session-state file present and within TTL / identity gates"
|
||||||
|
else:
|
||||||
|
result["summary"] = "session-state file present without a dict payload"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def load_state(
|
def load_state(
|
||||||
*,
|
*,
|
||||||
kind: str,
|
kind: str,
|
||||||
@@ -287,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)
|
||||||
@@ -297,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):
|
||||||
@@ -342,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(
|
||||||
@@ -354,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(
|
||||||
@@ -363,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):
|
||||||
@@ -392,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
|
||||||
|
|
||||||
@@ -425,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)
|
||||||
|
|
||||||
@@ -437,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,
|
||||||
@@ -446,4 +621,229 @@ 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(
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return profile identities that have a durable review_decision_lock file (#709).
|
||||||
|
|
||||||
|
Filename form: ``review_decision_lock-<profile>.json`` (see ``state_key``).
|
||||||
|
Does not validate TTL or identity — callers must load via ``load_state``.
|
||||||
|
"""
|
||||||
|
root = (state_dir or default_state_dir()).strip()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-"
|
||||||
|
suffix = ".json"
|
||||||
|
found: list[str] = []
|
||||||
|
try:
|
||||||
|
names = os.listdir(root)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
for name in names:
|
||||||
|
if not name.startswith(prefix) or not name.endswith(suffix):
|
||||||
|
continue
|
||||||
|
if name.endswith(".lock"):
|
||||||
|
continue
|
||||||
|
mid = name[len(prefix) : -len(suffix)]
|
||||||
|
if mid:
|
||||||
|
found.append(mid)
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def load_state_for_profile(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
profile_identity: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
skip_identity_match: bool = False,
|
||||||
|
enforce_repo_scope: bool = True,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Load durable state for an explicit profile identity (#709 cross-profile).
|
||||||
|
|
||||||
|
When *skip_identity_match* is True, still requires the file's recorded
|
||||||
|
profile_identity to equal the requested profile (anti-stomp), but does not
|
||||||
|
require the *active* session identity to match — needed so a merger can
|
||||||
|
inspect a reviewer lock after merge.
|
||||||
|
|
||||||
|
#709 F3: remote/org/repo filter reasons are **enforced** (not merely
|
||||||
|
computed). When *enforce_repo_scope* is True (default) and the caller
|
||||||
|
supplies remote/org/repo, mismatches fail closed with ``None``.
|
||||||
|
"""
|
||||||
|
# #709 F3: refuse traversal / malformed profile identities.
|
||||||
|
try:
|
||||||
|
from irrecoverable_provenance import assess_profile_path_identity
|
||||||
|
|
||||||
|
path_gate = assess_profile_path_identity(profile_identity)
|
||||||
|
if not path_gate.get("valid"):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Module may be mid-import in edge bootstraps; fall through to
|
||||||
|
# conservative checks below.
|
||||||
|
raw = str(profile_identity or "")
|
||||||
|
if ".." in raw or "/" in raw or "\\" in raw or "\x00" in raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
root = _ensure_state_dir(state_dir)
|
||||||
|
# Refuse symlink escape of the state root (#709 F3).
|
||||||
|
try:
|
||||||
|
real_root = os.path.realpath(root)
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=root,
|
||||||
|
)
|
||||||
|
real_path = os.path.realpath(path) if os.path.exists(path) else path
|
||||||
|
if os.path.exists(path) and not str(real_path).startswith(
|
||||||
|
str(real_root) + os.sep
|
||||||
|
) and str(real_path) != str(real_root):
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=root,
|
||||||
|
)
|
||||||
|
lock_path = f"{path}.lock"
|
||||||
|
with _exclusive_file_lock(lock_path):
|
||||||
|
envelope = _read_json(path)
|
||||||
|
if not envelope:
|
||||||
|
return None
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
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]
|
||||||
|
stored = (merged.get("profile_identity") or "").strip()
|
||||||
|
if stored and stored != profile:
|
||||||
|
return None
|
||||||
|
if skip_identity_match:
|
||||||
|
# Enforce remote/org/repo when caller provides them (#709 F3).
|
||||||
|
# Drop only *active session* profile-identity mismatches; keep
|
||||||
|
# expiry, spoof, and repository-scope reasons.
|
||||||
|
scope_remote = remote if enforce_repo_scope else None
|
||||||
|
scope_org = org if enforce_repo_scope else None
|
||||||
|
scope_repo = repo if enforce_repo_scope else None
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=scope_remote,
|
||||||
|
org=scope_org,
|
||||||
|
repo=scope_repo,
|
||||||
|
profile_identity=stored or profile,
|
||||||
|
)
|
||||||
|
filtered = [
|
||||||
|
r
|
||||||
|
for r in reasons
|
||||||
|
if "profile identity mismatch" not in r
|
||||||
|
or (stored and stored != profile)
|
||||||
|
]
|
||||||
|
# When caller requested a specific remote/org/repo, also fail if the
|
||||||
|
# stored record lacks those identity fields entirely (legacy incomplete).
|
||||||
|
if enforce_repo_scope:
|
||||||
|
for field, want in (
|
||||||
|
("remote", remote),
|
||||||
|
("org", org),
|
||||||
|
("repo", repo),
|
||||||
|
):
|
||||||
|
want_s = (want or "").strip()
|
||||||
|
if not want_s:
|
||||||
|
continue
|
||||||
|
have = (str(merged.get(field) or "")).strip()
|
||||||
|
if not have:
|
||||||
|
filtered.append(
|
||||||
|
f"session state {field} missing on durable record "
|
||||||
|
f"(expected={want_s!r}; fail closed, #709 F3)"
|
||||||
|
)
|
||||||
|
elif have != want_s:
|
||||||
|
# identity_match_reasons already adds mismatch; ensure kept
|
||||||
|
pass
|
||||||
|
if filtered:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""MCP tool-boundary error mapping for known Gitea client failures (#699).
|
||||||
|
|
||||||
|
Known authentication / authorization / network / configuration failures leave
|
||||||
|
the tool boundary as a sanitized structured ``CallToolResult`` with
|
||||||
|
``isError=True``. Stdio transport remains connected.
|
||||||
|
|
||||||
|
Design constraints (reviewer-ratified, #699 / PR #701):
|
||||||
|
|
||||||
|
1. **No secret material** in tool results or daemon logs. Messages are fixed
|
||||||
|
constants keyed by ``reason_code``; HTTP bodies / exception text never
|
||||||
|
surface. Sanitization failure fails closed to ``internal_error``.
|
||||||
|
2. **Narrow boundary** wraps the original FastMCP ``Tool.run`` success path;
|
||||||
|
re-raises framework control-flow exceptions (``UrlElicitationRequiredError``);
|
||||||
|
maps only typed client failures. Installation is idempotent.
|
||||||
|
3. **No RuntimeError substring heuristics.** Only explicit typed
|
||||||
|
authentication / authorization / network / configuration exceptions
|
||||||
|
receive those labels.
|
||||||
|
4. **HTTP classification** lives in ``gitea_auth.classify_http_status``;
|
||||||
|
every 403 is authorization-class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("gitea_mcp.tool_error_boundary")
|
||||||
|
|
||||||
|
# Stable reason codes (#699).
|
||||||
|
REASON_AUTH_FAILED = "auth_failed"
|
||||||
|
REASON_AUTH_INVALID_TOKEN = "auth_invalid_token"
|
||||||
|
REASON_AUTHZ_INSUFFICIENT_SCOPE = "authz_insufficient_scope"
|
||||||
|
REASON_AUTHZ_DENIED = "authz_denied"
|
||||||
|
REASON_NETWORK_ERROR = "network_error"
|
||||||
|
REASON_CONFIG_ERROR = "config_error"
|
||||||
|
REASON_INTERNAL_ERROR = "internal_error"
|
||||||
|
REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
||||||
|
REASON_HTTP_ERROR = "http_error"
|
||||||
|
|
||||||
|
ERROR_CLASS_AUTHENTICATION = "authentication"
|
||||||
|
ERROR_CLASS_AUTHORIZATION = "authorization"
|
||||||
|
ERROR_CLASS_NETWORK = "network"
|
||||||
|
ERROR_CLASS_CONFIGURATION = "configuration"
|
||||||
|
ERROR_CLASS_INTERNAL = "internal"
|
||||||
|
ERROR_CLASS_UPSTREAM = "upstream"
|
||||||
|
|
||||||
|
# Fixed, secret-free operator messages. Never interpolate HTTP bodies,
|
||||||
|
# Keychain contents, tokens, or arbitrary exception text.
|
||||||
|
FIXED_MESSAGES: dict[str, str] = {
|
||||||
|
REASON_AUTH_FAILED: "Gitea authentication failed",
|
||||||
|
REASON_AUTH_INVALID_TOKEN: (
|
||||||
|
"Gitea authentication failed: invalid or revoked credentials"
|
||||||
|
),
|
||||||
|
REASON_AUTHZ_INSUFFICIENT_SCOPE: (
|
||||||
|
"Gitea authorization failed: insufficient token scope"
|
||||||
|
),
|
||||||
|
REASON_AUTHZ_DENIED: "Gitea authorization failed: access denied",
|
||||||
|
REASON_NETWORK_ERROR: "Network error contacting Gitea",
|
||||||
|
REASON_CONFIG_ERROR: "Gitea configuration or credential resolution failed",
|
||||||
|
REASON_INTERNAL_ERROR: "Internal tool error",
|
||||||
|
REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable",
|
||||||
|
REASON_HTTP_ERROR: "Gitea HTTP request failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
_INSTALL_FLAG = "_gitea_auth_boundary_installed"
|
||||||
|
_ORIGINAL_ATTR = "_gitea_auth_boundary_original"
|
||||||
|
|
||||||
|
|
||||||
|
def fixed_message(reason_code: str) -> str:
|
||||||
|
"""Return the fixed sanitized message for *reason_code* (fail closed)."""
|
||||||
|
return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR])
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_profile_name() -> str | None:
|
||||||
|
try:
|
||||||
|
from gitea_auth import get_profile
|
||||||
|
|
||||||
|
name = (get_profile() or {}).get("profile_name")
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
text = str(name).strip()
|
||||||
|
# Profile names are non-secret identifiers; still bound length.
|
||||||
|
return text[:80] if text else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_framework_control_flow(exc: BaseException) -> bool:
|
||||||
|
"""True for exceptions the MCP framework must re-raise unchanged."""
|
||||||
|
try:
|
||||||
|
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||||
|
|
||||||
|
if isinstance(exc, UrlElicitationRequiredError):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# BaseException subclasses that must never become tool isError payloads.
|
||||||
|
if isinstance(exc, (KeyboardInterrupt, SystemExit, GeneratorExit)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_client_failure(exc: BaseException) -> bool:
|
||||||
|
"""True only for explicit typed Gitea client / config failures.
|
||||||
|
|
||||||
|
Never true for arbitrary ``RuntimeError`` (reviewer finding #3).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import gitea_auth
|
||||||
|
|
||||||
|
if isinstance(
|
||||||
|
exc,
|
||||||
|
(
|
||||||
|
gitea_auth.GiteaAuthError,
|
||||||
|
gitea_auth.GiteaAuthzError,
|
||||||
|
gitea_auth.GiteaNetworkError,
|
||||||
|
gitea_auth.GiteaConfigError,
|
||||||
|
gitea_auth.GiteaHttpError,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
if isinstance(exc, gitea_config.ConfigError):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def classify_exception(exc: BaseException) -> dict[str, Any]:
|
||||||
|
"""Return structured classification with **fixed** messages only.
|
||||||
|
|
||||||
|
Only typed authentication / authorization / network / configuration
|
||||||
|
failures receive those labels. Unexpected programming failures are
|
||||||
|
``internal_error``. HTTP bodies and exception text are never copied
|
||||||
|
into ``message``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _classify_exception_impl(exc)
|
||||||
|
except Exception:
|
||||||
|
# Fail closed: sanitization / classification failure must not leak.
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_exception_impl(exc: BaseException) -> dict[str, Any]:
|
||||||
|
import gitea_auth
|
||||||
|
|
||||||
|
if isinstance(exc, gitea_auth.GiteaAuthError):
|
||||||
|
code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN
|
||||||
|
if code not in (
|
||||||
|
REASON_AUTH_FAILED,
|
||||||
|
REASON_AUTH_INVALID_TOKEN,
|
||||||
|
):
|
||||||
|
code = REASON_AUTH_INVALID_TOKEN
|
||||||
|
return {
|
||||||
|
"reason_code": code,
|
||||||
|
"error_class": ERROR_CLASS_AUTHENTICATION,
|
||||||
|
"http_status": getattr(exc, "http_status", None) or 401,
|
||||||
|
"message": fixed_message(code),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaAuthzError):
|
||||||
|
code = getattr(exc, "reason_code", None) or REASON_AUTHZ_DENIED
|
||||||
|
if code not in (REASON_AUTHZ_DENIED, REASON_AUTHZ_INSUFFICIENT_SCOPE):
|
||||||
|
code = REASON_AUTHZ_DENIED
|
||||||
|
return {
|
||||||
|
"reason_code": code,
|
||||||
|
"error_class": ERROR_CLASS_AUTHORIZATION,
|
||||||
|
"http_status": getattr(exc, "http_status", None) or 403,
|
||||||
|
"message": fixed_message(code),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaNetworkError):
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_NETWORK_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_NETWORK,
|
||||||
|
"http_status": getattr(exc, "http_status", None),
|
||||||
|
"message": fixed_message(REASON_NETWORK_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaConfigError):
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_CONFIG_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_CONFIGURATION,
|
||||||
|
"http_status": getattr(exc, "http_status", None),
|
||||||
|
"message": fixed_message(REASON_CONFIG_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
if isinstance(exc, gitea_auth.GiteaHttpError):
|
||||||
|
code = getattr(exc, "reason_code", None) or REASON_HTTP_ERROR
|
||||||
|
if code == REASON_UPSTREAM_UNAVAILABLE:
|
||||||
|
error_class = ERROR_CLASS_UPSTREAM
|
||||||
|
else:
|
||||||
|
error_class = ERROR_CLASS_INTERNAL
|
||||||
|
code = REASON_HTTP_ERROR
|
||||||
|
return {
|
||||||
|
"reason_code": code,
|
||||||
|
"error_class": error_class,
|
||||||
|
"http_status": getattr(exc, "http_status", None),
|
||||||
|
"message": fixed_message(code),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import gitea_config
|
||||||
|
|
||||||
|
if isinstance(exc, gitea_config.ConfigError):
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_CONFIG_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_CONFIGURATION,
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(REASON_CONFIG_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Unwrap FastMCP ToolError cause when the original was a typed failure.
|
||||||
|
cause = getattr(exc, "__cause__", None)
|
||||||
|
if cause is not None and cause is not exc and is_known_client_failure(cause):
|
||||||
|
return _classify_exception_impl(cause)
|
||||||
|
|
||||||
|
# No message-substring authentication heuristics (reviewer finding #3).
|
||||||
|
return {
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"http_status": None,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_structured_error_payload(
|
||||||
|
classification: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""LLM-safe structured payload — fixed message + typed metadata only."""
|
||||||
|
try:
|
||||||
|
reason = str(classification.get("reason_code") or REASON_INTERNAL_ERROR)
|
||||||
|
message = fixed_message(reason)
|
||||||
|
# Refuse to emit any classification message that is not the fixed constant.
|
||||||
|
if classification.get("message") != message:
|
||||||
|
message = fixed_message(reason)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"success": False,
|
||||||
|
"isError": True,
|
||||||
|
"reason_code": reason if reason in FIXED_MESSAGES else REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": classification.get("error_class") or ERROR_CLASS_INTERNAL,
|
||||||
|
"message": message,
|
||||||
|
"transport_survives": True,
|
||||||
|
"retryable": classification.get("error_class")
|
||||||
|
in {
|
||||||
|
ERROR_CLASS_AUTHENTICATION,
|
||||||
|
ERROR_CLASS_NETWORK,
|
||||||
|
ERROR_CLASS_CONFIGURATION,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
status = classification.get("http_status")
|
||||||
|
if isinstance(status, int):
|
||||||
|
payload["http_status"] = status
|
||||||
|
if tool_name and isinstance(tool_name, str):
|
||||||
|
# Tool names are identifiers, not secrets; bound length.
|
||||||
|
payload["tool"] = tool_name[:120]
|
||||||
|
if profile_name and isinstance(profile_name, str):
|
||||||
|
payload["profile"] = profile_name[:80]
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"isError": True,
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
"retryable": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_sanitized_daemon_reason(
|
||||||
|
classification: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
stream=None,
|
||||||
|
) -> None:
|
||||||
|
"""Daemon log: reason codes only — never exception text or response bodies."""
|
||||||
|
stream = stream if stream is not None else sys.stderr
|
||||||
|
try:
|
||||||
|
reason = classification.get("reason_code") or REASON_INTERNAL_ERROR
|
||||||
|
error_class = classification.get("error_class") or ERROR_CLASS_INTERNAL
|
||||||
|
# Only emit known tokens; never classification['message'] from callers
|
||||||
|
# that might have been poisoned.
|
||||||
|
if reason not in FIXED_MESSAGES:
|
||||||
|
reason = REASON_INTERNAL_ERROR
|
||||||
|
error_class = ERROR_CLASS_INTERNAL
|
||||||
|
parts = [
|
||||||
|
"mcp_tool_error",
|
||||||
|
f"reason_code={reason}",
|
||||||
|
f"error_class={error_class}",
|
||||||
|
]
|
||||||
|
if tool_name and isinstance(tool_name, str):
|
||||||
|
parts.append(f"tool={tool_name[:120]}")
|
||||||
|
status = classification.get("http_status")
|
||||||
|
if isinstance(status, int):
|
||||||
|
parts.append(f"http_status={status}")
|
||||||
|
# Intentionally no detail= / message= field — secrets lived there.
|
||||||
|
line = " ".join(parts)
|
||||||
|
stream.write(line + "\n")
|
||||||
|
if hasattr(stream, "flush"):
|
||||||
|
stream.flush()
|
||||||
|
logger.warning(line)
|
||||||
|
except Exception:
|
||||||
|
# Fail closed: never fall back to logging the exception.
|
||||||
|
try:
|
||||||
|
stream.write(
|
||||||
|
"mcp_tool_error reason_code=internal_error "
|
||||||
|
"error_class=internal\n"
|
||||||
|
)
|
||||||
|
if hasattr(stream, "flush"):
|
||||||
|
stream.flush()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def to_call_tool_result(
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
tool_name: str | None = None,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
log: bool = True,
|
||||||
|
) -> Any:
|
||||||
|
"""Build a FastMCP ``CallToolResult`` with ``isError=True`` for *exc*."""
|
||||||
|
from mcp.types import CallToolResult, TextContent
|
||||||
|
|
||||||
|
try:
|
||||||
|
classification = classify_exception(exc)
|
||||||
|
if log:
|
||||||
|
log_sanitized_daemon_reason(classification, tool_name=tool_name)
|
||||||
|
payload = build_structured_error_payload(
|
||||||
|
classification, tool_name=tool_name, profile_name=profile_name
|
||||||
|
)
|
||||||
|
text = json.dumps(payload, indent=2, sort_keys=True)
|
||||||
|
return CallToolResult(
|
||||||
|
content=[TextContent(type="text", text=text)],
|
||||||
|
structuredContent=payload,
|
||||||
|
isError=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Absolute fail-closed path — no exception text.
|
||||||
|
fallback = {
|
||||||
|
"success": False,
|
||||||
|
"isError": True,
|
||||||
|
"reason_code": REASON_INTERNAL_ERROR,
|
||||||
|
"error_class": ERROR_CLASS_INTERNAL,
|
||||||
|
"message": fixed_message(REASON_INTERNAL_ERROR),
|
||||||
|
"transport_survives": True,
|
||||||
|
"retryable": False,
|
||||||
|
}
|
||||||
|
return CallToolResult(
|
||||||
|
content=[
|
||||||
|
TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps(fallback, indent=2, sort_keys=True),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
structuredContent=fallback,
|
||||||
|
isError=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_tool_run_boundary(Tool) -> bool:
|
||||||
|
"""Install a **narrow** error boundary around FastMCP ``Tool.run``.
|
||||||
|
|
||||||
|
Strategy (preserves framework semantics):
|
||||||
|
|
||||||
|
* Call the **original** ``Tool.run`` for the success path (async, return
|
||||||
|
types, convert_result, protocol behavior unchanged).
|
||||||
|
* Re-raise ``UrlElicitationRequiredError`` and other control-flow
|
||||||
|
exceptions without mapping to ``internal_error``.
|
||||||
|
* Map typed Gitea client failures (and ToolError whose ``__cause__`` is
|
||||||
|
typed) to structured ``CallToolResult(isError=True)``.
|
||||||
|
* Map remaining unexpected tool failures to fixed ``internal_error``
|
||||||
|
isError results (transport survival) without secret-bearing text.
|
||||||
|
* Idempotent: second install is a no-op and returns ``False``.
|
||||||
|
"""
|
||||||
|
if getattr(Tool.run, _INSTALL_FLAG, False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
from mcp.server.fastmcp.exceptions import ToolError
|
||||||
|
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||||
|
|
||||||
|
original_run = Tool.run
|
||||||
|
|
||||||
|
async def run_boundary(
|
||||||
|
self,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
context=None,
|
||||||
|
convert_result: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
return await original_run(
|
||||||
|
self,
|
||||||
|
arguments,
|
||||||
|
context=context,
|
||||||
|
convert_result=convert_result,
|
||||||
|
)
|
||||||
|
except UrlElicitationRequiredError:
|
||||||
|
# Framework control-flow — must not become internal_error.
|
||||||
|
raise
|
||||||
|
except BaseException as exc:
|
||||||
|
if _is_framework_control_flow(exc):
|
||||||
|
raise
|
||||||
|
if not isinstance(exc, Exception):
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Prefer typed cause under FastMCP ToolError wrappers.
|
||||||
|
target: BaseException = exc
|
||||||
|
if isinstance(exc, ToolError) and exc.__cause__ is not None:
|
||||||
|
target = exc.__cause__
|
||||||
|
|
||||||
|
# Known client failures → structured isError with reason codes.
|
||||||
|
# Unexpected failures → fixed internal_error isError (no secrets).
|
||||||
|
profile_name = _safe_profile_name()
|
||||||
|
return to_call_tool_result(
|
||||||
|
target,
|
||||||
|
tool_name=getattr(self, "name", None),
|
||||||
|
profile_name=profile_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
setattr(run_boundary, _INSTALL_FLAG, True)
|
||||||
|
setattr(run_boundary, _ORIGINAL_ATTR, original_run)
|
||||||
|
Tool.run = run_boundary # type: ignore[method-assign]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def boundary_is_installed(Tool) -> bool:
|
||||||
|
"""True when the #699 boundary is active on *Tool.run*."""
|
||||||
|
return bool(getattr(Tool.run, _INSTALL_FLAG, False))
|
||||||
@@ -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*[:=]|"
|
||||||
|
|||||||
+127
-8
@@ -20,12 +20,114 @@ if PROJECT_ROOT not in sys.path:
|
|||||||
import gitea_config
|
import gitea_config
|
||||||
|
|
||||||
|
|
||||||
AUTHOR_DEFAULT_ALLOWED = ["read", "branch", "commit", "push", "open_pr", "comment"]
|
# Defaults emit *canonical* operation names only. Shorthand that is not in
|
||||||
AUTHOR_DEFAULT_FORBIDDEN = ["approve", "request_changes", "merge"]
|
# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``)
|
||||||
REVIEWER_DEFAULT_ALLOWED = [
|
# is silently dropped by the production loader and must never appear here.
|
||||||
"read", "review", "comment", "approve", "request_changes", "merge"
|
AUTHOR_DEFAULT_ALLOWED = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
]
|
]
|
||||||
REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
|
AUTHOR_DEFAULT_FORBIDDEN = [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
]
|
||||||
|
REVIEWER_DEFAULT_ALLOWED = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
]
|
||||||
|
REVIEWER_DEFAULT_FORBIDDEN = [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.pr.create",
|
||||||
|
]
|
||||||
|
# Required reconciler ops (read + pr.close) plus recommended comment/close and
|
||||||
|
# branch.delete for guarded merged-PR cleanup. All names must normalize via
|
||||||
|
# gitea_config.normalize_operation without being dropped.
|
||||||
|
RECONCILER_DEFAULT_ALLOWED = [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
]
|
||||||
|
RECONCILER_DEFAULT_FORBIDDEN = [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Migration-only expansions for common shorthands that are *not* in
|
||||||
|
# GITEA_OPERATION_ALIASES. Emitted output is always the canonical form so a
|
||||||
|
# second canonicalize pass is a no-op (idempotent).
|
||||||
|
_MIGRATION_ONLY_ALIASES = {
|
||||||
|
"pr.close": "gitea.pr.close",
|
||||||
|
"pr.comment": "gitea.pr.comment",
|
||||||
|
"issue.close": "gitea.issue.close",
|
||||||
|
"branch.delete": "gitea.branch.delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reconciler required ops that must survive migration (from reconciler_profile).
|
||||||
|
RECONCILER_REQUIRED_CANONICAL = ("gitea.read", "gitea.pr.close")
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_operation(op: str) -> str:
|
||||||
|
"""Return a canonical operation name accepted by the production loader.
|
||||||
|
|
||||||
|
Fail closed on unknown/ambiguous spellings so required permissions cannot
|
||||||
|
be silently dropped by ``check_operation`` later.
|
||||||
|
"""
|
||||||
|
if not isinstance(op, str) or not op.strip():
|
||||||
|
raise ValueError("operation must be a non-empty string (fail closed)")
|
||||||
|
op = op.strip()
|
||||||
|
try:
|
||||||
|
return gitea_config.normalize_operation(op)
|
||||||
|
except gitea_config.ConfigError:
|
||||||
|
pass
|
||||||
|
if op in _MIGRATION_ONLY_ALIASES:
|
||||||
|
return _MIGRATION_ONLY_ALIASES[op]
|
||||||
|
raise ValueError(
|
||||||
|
f"operation {op!r} cannot be canonicalized for migration "
|
||||||
|
"(unknown/ambiguous; fail closed — production loader would drop it)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_operations(ops, *, context: str = "operations") -> list[str]:
|
||||||
|
"""Canonicalize a list of operations; preserve order, drop duplicates."""
|
||||||
|
if not isinstance(ops, list):
|
||||||
|
raise ValueError(f"{context} must be a list (fail closed)")
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in ops:
|
||||||
|
canon = canonicalize_operation(entry)
|
||||||
|
if canon not in seen:
|
||||||
|
seen.add(canon)
|
||||||
|
out.append(canon)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_reconciler_required_survive(allowed: list[str], profile_name: str) -> None:
|
||||||
|
"""Fail visibly when migration would leave a reconciler without required ops."""
|
||||||
|
missing = [op for op in RECONCILER_REQUIRED_CANONICAL if op not in set(allowed)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile_name}' (reconciler) is missing required "
|
||||||
|
f"operation(s) after migration: {missing}. Refusing to emit a "
|
||||||
|
"profile that would silently fail pr.close / read (fail closed)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def infer_role(name, execution_profile):
|
def infer_role(name, execution_profile):
|
||||||
@@ -90,9 +192,11 @@ def migrate_v1_to_v2(v1_data):
|
|||||||
ident_name = "reviewer"
|
ident_name = "reviewer"
|
||||||
elif role == "author":
|
elif role == "author":
|
||||||
ident_name = "author"
|
ident_name = "author"
|
||||||
|
elif role == "reconciler":
|
||||||
|
ident_name = "reconciler"
|
||||||
else:
|
else:
|
||||||
role = prof.get("role")
|
role = prof.get("role")
|
||||||
if role not in (None, "author", "reviewer"):
|
if role not in (None, "author", "reviewer", "reconciler"):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Profile '{name}' has unsupported role {role!r}"
|
f"Profile '{name}' has unsupported role {role!r}"
|
||||||
)
|
)
|
||||||
@@ -124,20 +228,35 @@ def migrate_v1_to_v2(v1_data):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Profile '{name}' operation fields must be lists"
|
f"Profile '{name}' operation fields must be lists"
|
||||||
)
|
)
|
||||||
identity_data["allowed_operations"] = list(allowed)
|
try:
|
||||||
identity_data["forbidden_operations"] = list(forbidden)
|
identity_data["allowed_operations"] = canonicalize_operations(
|
||||||
|
allowed, context=f"profile '{name}' allowed_operations"
|
||||||
|
)
|
||||||
|
identity_data["forbidden_operations"] = canonicalize_operations(
|
||||||
|
forbidden, context=f"profile '{name}' forbidden_operations"
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Profile '{name}': {exc}") from exc
|
||||||
elif role == "author":
|
elif role == "author":
|
||||||
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
|
identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED)
|
||||||
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
|
identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN)
|
||||||
elif role == "reviewer":
|
elif role == "reviewer":
|
||||||
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
|
identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED)
|
||||||
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
|
identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN)
|
||||||
|
elif role == "reconciler":
|
||||||
|
identity_data["allowed_operations"] = list(RECONCILER_DEFAULT_ALLOWED)
|
||||||
|
identity_data["forbidden_operations"] = list(RECONCILER_DEFAULT_FORBIDDEN)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Profile '{name}' has no explicit operation lists and no "
|
f"Profile '{name}' has no explicit operation lists and no "
|
||||||
"unambiguous author/reviewer role marker (fail closed)"
|
"unambiguous author/reviewer role marker (fail closed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if role == "reconciler":
|
||||||
|
_assert_reconciler_required_survive(
|
||||||
|
identity_data["allowed_operations"], name
|
||||||
|
)
|
||||||
|
|
||||||
# Nest inside environments/services structure
|
# Nest inside environments/services structure
|
||||||
env = environments.setdefault(env_name, {})
|
env = environments.setdefault(env_name, {})
|
||||||
services = env.setdefault("services", {})
|
services = env.setdefault("services", {})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,648 @@
|
|||||||
|
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
|
||||||
|
|
||||||
|
Pure assessment helpers for:
|
||||||
|
|
||||||
|
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
|
||||||
|
open PR when the base branch advances, approvals go stale, or conflicts appear.
|
||||||
|
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
|
||||||
|
of both PR head and live base head; never rebase/force-push.
|
||||||
|
|
||||||
|
All recommendation logic is hermetic (no network) so unit tests cover every
|
||||||
|
acceptance path without Gitea credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Recommended next actions (controller / skill vocabulary).
|
||||||
|
ACTION_MERGE_NOW = "merge_now"
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
|
||||||
|
ACTION_BLOCKED = "blocked"
|
||||||
|
|
||||||
|
_VALID_ACTIONS = frozenset({
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED,
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Author-only mutation; reviewer/merger must never update author branches.
|
||||||
|
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
|
||||||
|
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
|
||||||
|
|
||||||
|
# Allowed Gitea update style — merge only (never rebase).
|
||||||
|
UPDATE_STYLE_MERGE = "merge"
|
||||||
|
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
|
text = (value or "").strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text if _FULL_SHA.match(text) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_role(role: str | None) -> str:
|
||||||
|
return (role or "").strip().lower() or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def assess_pr_sync_status(
|
||||||
|
*,
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
pr_number: int,
|
||||||
|
pr_state: str | None = None,
|
||||||
|
source_branch: str | None = None,
|
||||||
|
pr_head_sha: str | None = None,
|
||||||
|
base_head_sha: str | None = None,
|
||||||
|
commits_behind: int | None = None,
|
||||||
|
mergeable: bool | None = None,
|
||||||
|
has_conflicts: bool | None = None,
|
||||||
|
branch_protection_requires_current_base: bool | None = None,
|
||||||
|
approval_at_current_head: bool | None = None,
|
||||||
|
checks_status: str | None = None,
|
||||||
|
active_author_lock: bool | None = None,
|
||||||
|
active_reviewer_lease: bool | None = None,
|
||||||
|
active_merger_lease: bool | None = None,
|
||||||
|
prepared_verdict_head_sha: str | None = None,
|
||||||
|
checks_required: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recommend the next sanctioned PR lifecycle action.
|
||||||
|
|
||||||
|
Decision order (fail closed):
|
||||||
|
|
||||||
|
1. Incomplete identity / open state / head SHAs → ``blocked``
|
||||||
|
2. Conflicts (or mergeable false with conflict signal) →
|
||||||
|
``author_conflict_remediation``
|
||||||
|
3. Head changed after approval / prepared verdict for former head →
|
||||||
|
``fresh_review_required`` (unless conflicts already routed author work)
|
||||||
|
4. Behind live base + branch protection requires current base +
|
||||||
|
auto-mergeable → ``update_branch_by_merge``
|
||||||
|
5. Approval at current head + mergeable + checks ok (+ update not
|
||||||
|
required) → ``merge_now``
|
||||||
|
6. Otherwise ``blocked`` with structured reasons
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
pr_head = _normalize_sha(pr_head_sha)
|
||||||
|
base_head = _normalize_sha(base_head_sha)
|
||||||
|
prepared_head = _normalize_sha(prepared_verdict_head_sha)
|
||||||
|
state = (pr_state or "").strip().lower()
|
||||||
|
checks = (checks_status or "unknown").strip().lower()
|
||||||
|
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
|
||||||
|
requires_current = bool(branch_protection_requires_current_base)
|
||||||
|
approval_ok = bool(approval_at_current_head)
|
||||||
|
|
||||||
|
# Derive conflict when caller only supplies mergeable=False.
|
||||||
|
if has_conflicts is None and mergeable is False:
|
||||||
|
has_conflicts = True
|
||||||
|
conflicts = bool(has_conflicts) if has_conflicts is not None else None
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"host": (host or "").strip() or None,
|
||||||
|
"org": (org or "").strip() or None,
|
||||||
|
"repo": (repo or "").strip() or None,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"pr_state": state or None,
|
||||||
|
"source_branch": (source_branch or "").strip() or None,
|
||||||
|
"pr_head_sha": pr_head,
|
||||||
|
"base_head_sha": base_head,
|
||||||
|
"commits_behind": behind,
|
||||||
|
"mergeable": mergeable,
|
||||||
|
"has_conflicts": conflicts,
|
||||||
|
"branch_protection_requires_current_base": requires_current,
|
||||||
|
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||||
|
"checks_status": checks,
|
||||||
|
"active_locks_and_leases": {
|
||||||
|
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
||||||
|
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||||
|
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
|
||||||
|
},
|
||||||
|
"prepared_verdict_head_sha": prepared_head,
|
||||||
|
"recommended_next_action": ACTION_BLOCKED,
|
||||||
|
"approval_valid_for_merge": False,
|
||||||
|
"stale_approval": False,
|
||||||
|
"stale_prepared_verdict": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
"performed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||||
|
reasons.append("pr_number must be a positive integer (fail closed)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if state and state not in ("open",):
|
||||||
|
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
if not state:
|
||||||
|
reasons.append("PR state missing (fail closed)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if pr_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||||
|
)
|
||||||
|
if base_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
|
||||||
|
)
|
||||||
|
if behind is None:
|
||||||
|
reasons.append("commits_behind missing or invalid (fail closed)")
|
||||||
|
if mergeable is None:
|
||||||
|
reasons.append("mergeable signal missing (fail closed)")
|
||||||
|
if approval_at_current_head is None:
|
||||||
|
reasons.append("approval_at_current_head missing (fail closed)")
|
||||||
|
if branch_protection_requires_current_base is None:
|
||||||
|
reasons.append(
|
||||||
|
"branch_protection_requires_current_base missing (fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Stale prepared verdict / approval across heads.
|
||||||
|
stale_prepared = bool(prepared_head and prepared_head != pr_head)
|
||||||
|
result["stale_prepared_verdict"] = stale_prepared
|
||||||
|
stale_approval = not approval_ok
|
||||||
|
result["stale_approval"] = stale_approval
|
||||||
|
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
|
||||||
|
|
||||||
|
# ── Conflicts: author remediation in dedicated worktree ──────────────
|
||||||
|
if conflicts is True or mergeable is False:
|
||||||
|
if conflicts is True or mergeable is False:
|
||||||
|
# Distinguish pure non-mergeable without explicit conflict flag
|
||||||
|
# still routes author remediation (Gitea cannot auto-update).
|
||||||
|
reasons.append(
|
||||||
|
f"PR #{pr_number} has conflicts or is not mergeable at head "
|
||||||
|
f"{pr_head}; route author conflict remediation in the existing "
|
||||||
|
"issue/PR worktree under branches/ (never force-push or rebase)"
|
||||||
|
)
|
||||||
|
if stale_approval:
|
||||||
|
reasons.append(
|
||||||
|
"any approval at a former head is invalid; after remediation "
|
||||||
|
"require a fresh independent review at the new exact head"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Stale approval / prepared verdict at former head ─────────────────
|
||||||
|
if not approval_ok or stale_prepared:
|
||||||
|
if behind and behind > 0 and requires_current and mergeable is True:
|
||||||
|
# Must update first; update will also invalidate approval.
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base and branch protection "
|
||||||
|
"requires current base; automatic merge-from-base is available"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"approval is not valid at the current head (or prepared verdict "
|
||||||
|
"is head-scoped to a former SHA); after update require fresh review"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
reasons.append(
|
||||||
|
"approval is not valid at the exact current PR head "
|
||||||
|
f"({pr_head}); never reuse a former-head approval for merge"
|
||||||
|
)
|
||||||
|
if stale_prepared:
|
||||||
|
reasons.append(
|
||||||
|
f"prepared verdict pinned to former head {prepared_head} cannot "
|
||||||
|
f"authorize review/merge at current head {pr_head}"
|
||||||
|
)
|
||||||
|
if active_reviewer_lease:
|
||||||
|
reasons.append(
|
||||||
|
"active reviewer lease must be released or superseded for the "
|
||||||
|
"new head before a fresh independent review"
|
||||||
|
)
|
||||||
|
if active_merger_lease:
|
||||||
|
reasons.append(
|
||||||
|
"active merger lease cannot cross heads; release or re-acquire "
|
||||||
|
"at the new exact head"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Outdated + protection requires current base, auto-updatable ──────
|
||||||
|
if behind and behind > 0 and requires_current:
|
||||||
|
if mergeable is True and conflicts is not True:
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base {base_head}; "
|
||||||
|
"branch protection requires the PR branch to contain current base; "
|
||||||
|
"Gitea can merge base into the PR branch without conflicts"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"route author-only update_branch_by_merge; never rebase or force-push; "
|
||||||
|
"new head invalidates prior approval and requires fresh independent review"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
# Approval today is at old head that will change — not mergeable yet
|
||||||
|
# for final merge until re-reviewed, but assess marks update path.
|
||||||
|
result["approval_valid_for_merge"] = False
|
||||||
|
result["stale_approval"] = True # will become stale after update
|
||||||
|
return result
|
||||||
|
reasons.append(
|
||||||
|
"update required by branch protection but automatic merge is not available"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Checks gate for merge_now ────────────────────────────────────────
|
||||||
|
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
||||||
|
if checks in ("pending", "running", "queued"):
|
||||||
|
reasons.append(f"required checks are not finished (status={checks})")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
if checks in ("failure", "failed", "error", "cancelled"):
|
||||||
|
reasons.append(f"required checks failed (status={checks})")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
# unknown — fail closed when checks_required
|
||||||
|
if checks == "unknown":
|
||||||
|
reasons.append("checks status unknown (fail closed)")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── Ready to merge without update ────────────────────────────────────
|
||||||
|
# Includes: current with approval; outdated when update is NOT required.
|
||||||
|
if approval_ok and mergeable is True and conflicts is not True:
|
||||||
|
if behind and behind > 0 and not requires_current:
|
||||||
|
reasons.append(
|
||||||
|
f"PR is {behind} commit(s) behind live base but branch protection "
|
||||||
|
"does not require the latest base; preserve the approved head"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"PR has a valid approval at the exact current head, is conflict-free "
|
||||||
|
"and mergeable; do not update the branch unnecessarily"
|
||||||
|
)
|
||||||
|
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
|
||||||
|
result["recommended_next_action"] = ACTION_MERGE_NOW
|
||||||
|
result["approval_valid_for_merge"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_update_pr_branch_preflight(
|
||||||
|
*,
|
||||||
|
role_kind: str | None,
|
||||||
|
expected_pr_head_sha: str | None,
|
||||||
|
live_pr_head_sha: str | None,
|
||||||
|
expected_base_head_sha: str | None,
|
||||||
|
live_base_head_sha: str | None,
|
||||||
|
has_conflicts: bool | None = None,
|
||||||
|
mergeable: bool | None = None,
|
||||||
|
style: str | None = UPDATE_STYLE_MERGE,
|
||||||
|
has_author_lock: bool | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
worktree_owns_source_branch: bool | None = None,
|
||||||
|
control_checkout_clean: bool | None = None,
|
||||||
|
master_parity_ok: bool | None = None,
|
||||||
|
force_push: bool = False,
|
||||||
|
rebase: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Author-only preflight for update-by-merge; fail closed on any race.
|
||||||
|
|
||||||
|
When conflicts exist, returns a structured author-remediation handoff and
|
||||||
|
sets ``mutation_allowed=False`` so no partial remote update is performed.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
role = _normalize_role(role_kind)
|
||||||
|
exp_pr = _normalize_sha(expected_pr_head_sha)
|
||||||
|
live_pr = _normalize_sha(live_pr_head_sha)
|
||||||
|
exp_base = _normalize_sha(expected_base_head_sha)
|
||||||
|
live_base = _normalize_sha(live_base_head_sha)
|
||||||
|
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
|
||||||
|
|
||||||
|
conflicts = has_conflicts
|
||||||
|
if conflicts is None and mergeable is False:
|
||||||
|
conflicts = True
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"mutation_allowed": False,
|
||||||
|
"performed": False,
|
||||||
|
"style": style_norm,
|
||||||
|
"role_kind": role,
|
||||||
|
"expected_pr_head_sha": exp_pr,
|
||||||
|
"live_pr_head_sha": live_pr,
|
||||||
|
"expected_base_head_sha": exp_base,
|
||||||
|
"live_base_head_sha": live_base,
|
||||||
|
"head_race": False,
|
||||||
|
"base_race": False,
|
||||||
|
"has_conflicts": conflicts,
|
||||||
|
"author_remediation_handoff": False,
|
||||||
|
"approval_invalidated_for_former_head": False,
|
||||||
|
"force_push": bool(force_push),
|
||||||
|
"rebase": bool(rebase),
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role gate — author only.
|
||||||
|
if role not in _AUTHOR_UPDATE_ROLES:
|
||||||
|
reasons.append(
|
||||||
|
f"role '{role}' cannot update an author PR branch "
|
||||||
|
"(author-only; reviewer/merger denial)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if force_push:
|
||||||
|
reasons.append("force-push is forbidden for PR branch update (fail closed)")
|
||||||
|
return result
|
||||||
|
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
|
||||||
|
reasons.append(
|
||||||
|
f"update style '{style_norm}' forbidden; only style=merge is allowed "
|
||||||
|
"(never rebase)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if style_norm != UPDATE_STYLE_MERGE:
|
||||||
|
reasons.append(
|
||||||
|
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not exp_pr or not live_pr:
|
||||||
|
reasons.append(
|
||||||
|
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
|
||||||
|
)
|
||||||
|
if not exp_base or not live_base:
|
||||||
|
reasons.append(
|
||||||
|
"expected and live base head SHAs must be full 40-char hex (fail closed)"
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if exp_pr != live_pr:
|
||||||
|
result["head_race"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"PR head race: expected {exp_pr} but live is {live_pr} "
|
||||||
|
"(fail closed; no partial mutation)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
if exp_base != live_base:
|
||||||
|
result["base_race"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"base head race: expected {exp_base} but live is {live_base} "
|
||||||
|
"(fail closed; no partial mutation)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if has_author_lock is not True:
|
||||||
|
reasons.append(
|
||||||
|
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
|
||||||
|
)
|
||||||
|
wt = (worktree_path or "").strip()
|
||||||
|
if not wt:
|
||||||
|
reasons.append("existing PR worktree path required under branches/ (fail closed)")
|
||||||
|
else:
|
||||||
|
norm = wt.replace("\\", "/")
|
||||||
|
if "/branches/" not in norm and not norm.startswith("branches/"):
|
||||||
|
reasons.append(
|
||||||
|
f"worktree_path '{wt}' must be under branches/ (fail closed)"
|
||||||
|
)
|
||||||
|
if worktree_owns_source_branch is False:
|
||||||
|
reasons.append(
|
||||||
|
"worktree does not own the PR source branch (fail closed)"
|
||||||
|
)
|
||||||
|
if control_checkout_clean is False:
|
||||||
|
reasons.append("control checkout is not clean (fail closed)")
|
||||||
|
if master_parity_ok is False:
|
||||||
|
reasons.append("runtime/master parity failed (fail closed)")
|
||||||
|
|
||||||
|
if conflicts is True:
|
||||||
|
result["author_remediation_handoff"] = True
|
||||||
|
reasons.append(
|
||||||
|
"conflicts present: return structured author-remediation handoff "
|
||||||
|
"without creating a partial remote update"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"resolve conflicts explicitly in the existing dedicated worktree, "
|
||||||
|
"run focused and regression tests, commit/push via sanctioned author "
|
||||||
|
"workflow, then route the new exact head to independent review"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if mergeable is False and conflicts is not False:
|
||||||
|
result["author_remediation_handoff"] = True
|
||||||
|
reasons.append(
|
||||||
|
"PR is not mergeable; refuse automatic update and hand off to "
|
||||||
|
"author conflict remediation (fail closed)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["mutation_allowed"] = True
|
||||||
|
reasons.append(
|
||||||
|
"preflight passed: author may merge live base into the PR branch via "
|
||||||
|
"native MCP update (style=merge only)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_post_update_head_transition(
|
||||||
|
*,
|
||||||
|
former_pr_head_sha: str | None,
|
||||||
|
new_pr_head_sha: str | None,
|
||||||
|
former_approval_head_sha: str | None = None,
|
||||||
|
former_reviewer_lease_head_sha: str | None = None,
|
||||||
|
former_merger_lease_head_sha: str | None = None,
|
||||||
|
prepared_verdict_head_sha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""After a successful update-by-merge, invalidate cross-head artifacts.
|
||||||
|
|
||||||
|
The new head never inherits approval, reviewer/merger leases, or prepared
|
||||||
|
verdicts from the former head.
|
||||||
|
"""
|
||||||
|
former = _normalize_sha(former_pr_head_sha)
|
||||||
|
new = _normalize_sha(new_pr_head_sha)
|
||||||
|
reasons: list[str] = []
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"former_pr_head_sha": former,
|
||||||
|
"new_pr_head_sha": new,
|
||||||
|
"head_changed": bool(former and new and former != new),
|
||||||
|
"approval_invalidated": False,
|
||||||
|
"reviewer_lease_superseded": False,
|
||||||
|
"merger_lease_superseded": False,
|
||||||
|
"prepared_verdict_invalidated": False,
|
||||||
|
"recommended_next_action": ACTION_BLOCKED,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not former or not new:
|
||||||
|
reasons.append("former and new PR head SHAs required (fail closed)")
|
||||||
|
return result
|
||||||
|
if former == new:
|
||||||
|
reasons.append(
|
||||||
|
"PR head unchanged after update; unexpected for merge-from-base"
|
||||||
|
)
|
||||||
|
result["recommended_next_action"] = ACTION_BLOCKED
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["head_changed"] = True
|
||||||
|
# Approval at former head is always void at new head.
|
||||||
|
former_approval = _normalize_sha(former_approval_head_sha) or former
|
||||||
|
if former_approval != new:
|
||||||
|
result["approval_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"approval for former head {former_approval} is invalid at new head "
|
||||||
|
f"{new}; never preserve approval across a head change"
|
||||||
|
)
|
||||||
|
|
||||||
|
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
|
||||||
|
if rev_lease and rev_lease != new:
|
||||||
|
result["reviewer_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
|
||||||
|
"release or supersede before fresh review"
|
||||||
|
)
|
||||||
|
elif rev_lease is None:
|
||||||
|
# Unknown lease head still must not authorize at new head.
|
||||||
|
result["reviewer_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
"any reviewer lease scoped to the former head is superseded at the new head"
|
||||||
|
)
|
||||||
|
|
||||||
|
mer_lease = _normalize_sha(former_merger_lease_head_sha)
|
||||||
|
if mer_lease and mer_lease != new:
|
||||||
|
result["merger_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"merger lease for head {mer_lease} cannot cross to {new}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["merger_lease_superseded"] = True
|
||||||
|
reasons.append(
|
||||||
|
"any merger lease scoped to the former head is superseded at the new head"
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = _normalize_sha(prepared_verdict_head_sha)
|
||||||
|
if prepared and prepared != new:
|
||||||
|
result["prepared_verdict_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
|
||||||
|
)
|
||||||
|
elif prepared is None:
|
||||||
|
result["prepared_verdict_invalidated"] = True
|
||||||
|
reasons.append(
|
||||||
|
"prepared verdicts associated with the former head are invalidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
reasons.append(
|
||||||
|
"route the new exact head to independent review "
|
||||||
|
f"({ACTION_FRESH_REVIEW_REQUIRED})"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_sequential_queue_step(
|
||||||
|
*,
|
||||||
|
just_merged: bool,
|
||||||
|
master_refreshed: bool,
|
||||||
|
next_pr_number: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Controller sequential processing: reassess only after merge + master refresh."""
|
||||||
|
reasons: list[str] = []
|
||||||
|
if just_merged and not master_refreshed:
|
||||||
|
reasons.append(
|
||||||
|
"master must be refreshed after each merge before reassessing the next PR "
|
||||||
|
"(do not update every PR simultaneously)"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reassess_allowed": False,
|
||||||
|
"process_next": False,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
if not just_merged:
|
||||||
|
reasons.append("no merge completed; sequential step does not advance")
|
||||||
|
return {
|
||||||
|
"reassess_allowed": False,
|
||||||
|
"process_next": False,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
if next_pr_number:
|
||||||
|
reasons.append(
|
||||||
|
"merge completed and live master refreshed; reassess the next PR "
|
||||||
|
f"#{next_pr_number}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reasons.append(
|
||||||
|
"merge completed and live master refreshed; reassess the next PR"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"reassess_allowed": True,
|
||||||
|
"process_next": True,
|
||||||
|
"next_pr_number": next_pr_number,
|
||||||
|
"post_merge_cleanup_handoff": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_approval_usable_at_head(
|
||||||
|
*,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
approved_head_sha: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Stale approval cannot authorize merge (head-scoped only)."""
|
||||||
|
current = _normalize_sha(current_head_sha)
|
||||||
|
approved = _normalize_sha(approved_head_sha)
|
||||||
|
ok = bool(current and approved and current == approved)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not ok:
|
||||||
|
reasons.append(
|
||||||
|
f"stale approval: approved head '{approved or '(none)'}' does not "
|
||||||
|
f"match current head '{current or '(none)'}'; cannot authorize merge"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"approval_usable": ok,
|
||||||
|
"current_head_sha": current,
|
||||||
|
"approved_head_sha": approved,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lease_usable_at_head(
|
||||||
|
*,
|
||||||
|
lease_kind: str,
|
||||||
|
lease_head_sha: str | None,
|
||||||
|
current_head_sha: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reviewer/merger leases cannot cross heads."""
|
||||||
|
current = _normalize_sha(current_head_sha)
|
||||||
|
lease_head = _normalize_sha(lease_head_sha)
|
||||||
|
kind = (lease_kind or "").strip().lower() or "unknown"
|
||||||
|
ok = bool(current and lease_head and current == lease_head)
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not ok:
|
||||||
|
reasons.append(
|
||||||
|
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
|
||||||
|
f"authorize work at current head '{current or '(none)'}'"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"lease_usable": ok,
|
||||||
|
"lease_kind": kind,
|
||||||
|
"lease_head_sha": lease_head,
|
||||||
|
"current_head_sha": current,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ RECONCILER_RECOMMENDED_OPERATIONS = (
|
|||||||
"gitea.pr.comment",
|
"gitea.pr.comment",
|
||||||
"gitea.issue.comment",
|
"gitea.issue.comment",
|
||||||
"gitea.issue.close",
|
"gitea.issue.close",
|
||||||
|
# Merged-branch cleanup is reconciler-owned (task_capability_map maps
|
||||||
|
# cleanup_merged_pr_branch -> reconciler). The permission is only
|
||||||
|
# exercisable through the guarded gitea_cleanup_merged_pr_branch path
|
||||||
|
# (#514): merged proof, protected-branch refusal, explicit confirmation.
|
||||||
|
"gitea.branch.delete",
|
||||||
)
|
)
|
||||||
|
|
||||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
RECONCILER_FORBIDDEN_OPERATIONS = (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ python-multipart==0.0.32
|
|||||||
referencing==0.37.0
|
referencing==0.37.0
|
||||||
rich==15.0.0
|
rich==15.0.0
|
||||||
rpds-py==2026.5.1
|
rpds-py==2026.5.1
|
||||||
|
sentry-sdk==2.20.0
|
||||||
shellingham==1.5.4
|
shellingham==1.5.4
|
||||||
sse-starlette==3.4.5
|
sse-starlette==3.4.5
|
||||||
starlette==1.3.1
|
starlette==1.3.1
|
||||||
|
|||||||
@@ -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,535 @@
|
|||||||
|
"""Optional self-hosted Sentry observability for the Gitea MCP server (#606).
|
||||||
|
|
||||||
|
Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed
|
||||||
|
workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring
|
||||||
|
watchdog check-ins are visible in a *self-hosted* Sentry at
|
||||||
|
``https://sentry.prgs.cc/`` — never Sentry Cloud, and never as the workflow
|
||||||
|
source of truth (Gitea stays canonical).
|
||||||
|
|
||||||
|
Design constraints (mirror ``gitea_audit`` and the #612 incident bridge):
|
||||||
|
|
||||||
|
- **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN``
|
||||||
|
empty, ``init_sentry`` is a no-op and no events are ever sent — existing tool
|
||||||
|
behaviour and API-call patterns are unchanged (acceptance criterion 1).
|
||||||
|
- **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk``
|
||||||
|
package, or any capture error must never break an MCP tool success path. Every
|
||||||
|
public entry point swallows its own exceptions.
|
||||||
|
- **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped
|
||||||
|
rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw
|
||||||
|
session-state, full prompt bodies, and full filesystem paths never leave here.
|
||||||
|
- **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully
|
||||||
|
importable and testable without it installed.
|
||||||
|
|
||||||
|
Sentry is observe-only: it must not approve, merge, close, or otherwise mutate
|
||||||
|
Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed
|
||||||
|
the sanctioned Gitea issue/comment path via the #612 incident bridge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Reuse the most comprehensive existing scrubber so redaction stays consistent
|
||||||
|
# with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain
|
||||||
|
# ids, session ids, user:pass@host).
|
||||||
|
from incident_bridge import redact_text as _redact_text
|
||||||
|
|
||||||
|
# Second, complementary scrubber: catches bare ``token <value>`` /
|
||||||
|
# ``Bearer <value>`` / ``Basic <value>`` prefixes and raw URLs that the
|
||||||
|
# incident-bridge delimiter patterns miss.
|
||||||
|
from gitea_audit import _redact_str as _redact_prefixes
|
||||||
|
|
||||||
|
# ── Optional SDK (lazy, never a hard dependency) ────────────────────────────
|
||||||
|
try: # pragma: no cover - trivial import guard
|
||||||
|
import sentry_sdk # type: ignore
|
||||||
|
except Exception: # pragma: no cover - absence is a supported state
|
||||||
|
sentry_sdk = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# ── Env var names (single source of truth) ──────────────────────────────────
|
||||||
|
ENV_ENABLED = "MCP_SENTRY_ENABLED"
|
||||||
|
ENV_DSN = "SENTRY_DSN"
|
||||||
|
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
|
||||||
|
ENV_RELEASE = "SENTRY_RELEASE"
|
||||||
|
ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE"
|
||||||
|
ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS"
|
||||||
|
|
||||||
|
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||||
|
|
||||||
|
REDACTED = "[REDACTED]"
|
||||||
|
REDACTED_PATH = "[REDACTED_PATH]"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cron / watchdog monitor slugs (acceptance criterion 6) ──────────────────
|
||||||
|
# Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The
|
||||||
|
# slug is the durable monitor identity in Sentry; the wiring call sites pass one
|
||||||
|
# of these keys (or an explicit slug) to ``monitor_checkin``.
|
||||||
|
MONITOR_SLUGS: dict[str, str] = {
|
||||||
|
"stale_lease_scan": "gitea-mcp-stale-lease-scan",
|
||||||
|
"terminal_lock_scan": "gitea-mcp-terminal-lock-scan",
|
||||||
|
"allocator_health": "gitea-mcp-allocator-health",
|
||||||
|
"namespace_health": "gitea-mcp-namespace-health",
|
||||||
|
"dashboard_freshness": "gitea-mcp-dashboard-freshness",
|
||||||
|
"reconciler_cleanup": "gitea-mcp-reconciler-cleanup",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tag allowlist (issue "Suggested Sentry tags/context") ───────────────────
|
||||||
|
# Only these keys are ever attached as Sentry tags. Anything else is dropped so
|
||||||
|
# a caller cannot accidentally leak a sensitive value through a tag.
|
||||||
|
ALLOWED_TAG_KEYS = frozenset({
|
||||||
|
"role",
|
||||||
|
"profile",
|
||||||
|
"namespace",
|
||||||
|
"repo",
|
||||||
|
"org",
|
||||||
|
"issue_number",
|
||||||
|
"pr_number",
|
||||||
|
"blocker_type",
|
||||||
|
"workflow_hash",
|
||||||
|
"session_id_hash", # hash only — never the raw session id
|
||||||
|
"pid",
|
||||||
|
"worktree_category", # category, never the full sensitive path
|
||||||
|
"lease_comment_id",
|
||||||
|
"expected_head_sha",
|
||||||
|
"current_head_sha",
|
||||||
|
"terminal_lock_state",
|
||||||
|
"capability",
|
||||||
|
"mutation_tool",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp).
|
||||||
|
_PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*")
|
||||||
|
|
||||||
|
# ``extra`` keys whose *full* contents are forbidden by the redaction rules
|
||||||
|
# (raw session-state, full prompt/comment bodies, private config blobs, raw
|
||||||
|
# headers).
|
||||||
|
_FORBIDDEN_EXTRA_KEYS = frozenset({
|
||||||
|
"prompt",
|
||||||
|
"prompt_body",
|
||||||
|
"next_prompt",
|
||||||
|
"body",
|
||||||
|
"raw_body",
|
||||||
|
"session_state",
|
||||||
|
"session_state_contents",
|
||||||
|
"config",
|
||||||
|
"config_contents",
|
||||||
|
"private_config",
|
||||||
|
"headers",
|
||||||
|
"authorization",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SentryConfig:
|
||||||
|
"""Immutable snapshot of the Sentry env configuration."""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
dsn: str | None = None
|
||||||
|
environment: str = "development"
|
||||||
|
release: str | None = None
|
||||||
|
traces_sample_rate: float = 0.0
|
||||||
|
enable_logs: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
"""True only when the operator both opted in *and* supplied a DSN.
|
||||||
|
|
||||||
|
This is the single gate that keeps the feature off by default: enabling
|
||||||
|
the flag without a DSN (or vice versa) sends nothing.
|
||||||
|
"""
|
||||||
|
return bool(self.enabled and self.dsn)
|
||||||
|
|
||||||
|
def safe_summary(self) -> dict[str, Any]:
|
||||||
|
"""Operator-facing status with **no** DSN value (only presence)."""
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"dsn_present": bool(self.dsn),
|
||||||
|
"environment": self.environment,
|
||||||
|
"release": self.release,
|
||||||
|
"traces_sample_rate": self.traces_sample_rate,
|
||||||
|
"enable_logs": self.enable_logs,
|
||||||
|
"active": self.active,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, env: dict[str, str]) -> bool:
|
||||||
|
return (env.get(name) or "").strip().lower() in _TRUTHY
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(name: str, default: float, env: dict[str, str]) -> float:
|
||||||
|
raw = (env.get(name) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
val = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
# Clamp to Sentry's valid [0.0, 1.0] sample-rate range.
|
||||||
|
if val < 0.0:
|
||||||
|
return 0.0
|
||||||
|
if val > 1.0:
|
||||||
|
return 1.0
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(env: dict[str, str] | None = None) -> SentryConfig:
|
||||||
|
"""Build a :class:`SentryConfig` from the environment (read at call time)."""
|
||||||
|
env = dict(os.environ if env is None else env)
|
||||||
|
dsn = (env.get(ENV_DSN) or "").strip() or None
|
||||||
|
return SentryConfig(
|
||||||
|
enabled=_env_bool(ENV_ENABLED, env),
|
||||||
|
dsn=dsn,
|
||||||
|
environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development",
|
||||||
|
release=(env.get(ENV_RELEASE) or "").strip() or None,
|
||||||
|
traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env),
|
||||||
|
enable_logs=_env_bool(ENV_ENABLE_LOGS, env),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sdk_available() -> bool:
|
||||||
|
"""True when the optional ``sentry_sdk`` package is importable."""
|
||||||
|
return sentry_sdk is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
||||||
|
def sanitize_path(value: Any) -> str:
|
||||||
|
"""Reduce a filesystem path to a non-sensitive *category* token.
|
||||||
|
|
||||||
|
Full local paths must never be sent. We keep only a coarse worktree
|
||||||
|
category derived from the path shape (author/reviewer/merger/reconciler/
|
||||||
|
branches/root/other).
|
||||||
|
"""
|
||||||
|
text = "" if value is None else str(value)
|
||||||
|
low = text.lower()
|
||||||
|
if not text:
|
||||||
|
return "unknown"
|
||||||
|
# Order matters: more specific role markers before the generic "branches".
|
||||||
|
if "reconcile" in low:
|
||||||
|
return "reconciler"
|
||||||
|
if "review" in low:
|
||||||
|
return "reviewer"
|
||||||
|
if "merge" in low or "merger" in low:
|
||||||
|
return "merger"
|
||||||
|
if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low):
|
||||||
|
return "author"
|
||||||
|
if "/branches/" in low:
|
||||||
|
return "branches"
|
||||||
|
if low.rstrip("/").endswith("gitea-tools"):
|
||||||
|
return "root"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_value(value: Any) -> Any:
|
||||||
|
"""Recursively redact a JSON-able value: secret text, absolute paths, and
|
||||||
|
known-sensitive dict keys are removed. Fail closed — any error drops the
|
||||||
|
value entirely rather than risk leaking it."""
|
||||||
|
try:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for k, v in value.items():
|
||||||
|
key = str(k)
|
||||||
|
low = key.lower()
|
||||||
|
if low in _FORBIDDEN_EXTRA_KEYS or any(
|
||||||
|
s in low
|
||||||
|
for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain")
|
||||||
|
):
|
||||||
|
out[key] = REDACTED
|
||||||
|
continue
|
||||||
|
out[key] = redact_value(v)
|
||||||
|
return out
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [redact_value(v) for v in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
scrubbed = _redact_text(value)
|
||||||
|
scrubbed = _redact_prefixes(scrubbed)
|
||||||
|
scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed)
|
||||||
|
return scrubbed
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
return REDACTED
|
||||||
|
|
||||||
|
|
||||||
|
def hash_session_id(session_id: Any) -> str:
|
||||||
|
"""Short, stable, non-reversible fingerprint of a session id."""
|
||||||
|
digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest()
|
||||||
|
return digest[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def build_tags(**kwargs: Any) -> dict[str, str]:
|
||||||
|
"""Return a scrubbed, allowlisted tag dict.
|
||||||
|
|
||||||
|
``session_id`` is accepted but only ever surfaced as ``session_id_hash``.
|
||||||
|
``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted
|
||||||
|
key, or a value that still contains redacted material after scrubbing, is
|
||||||
|
dropped.
|
||||||
|
"""
|
||||||
|
raw: dict[str, Any] = dict(kwargs)
|
||||||
|
|
||||||
|
# Hash the session id — never emit it raw.
|
||||||
|
session_id = raw.pop("session_id", None)
|
||||||
|
if session_id and "session_id_hash" not in raw:
|
||||||
|
raw["session_id_hash"] = hash_session_id(session_id)
|
||||||
|
|
||||||
|
# A full worktree path collapses to a category tag.
|
||||||
|
wt = raw.pop("worktree_path", None)
|
||||||
|
if wt and "worktree_category" not in raw:
|
||||||
|
raw["worktree_category"] = sanitize_path(wt)
|
||||||
|
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for key, val in raw.items():
|
||||||
|
if key not in ALLOWED_TAG_KEYS:
|
||||||
|
continue
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
scrubbed = redact_value(val)
|
||||||
|
text = str(scrubbed)
|
||||||
|
if not text or REDACTED in text or REDACTED_PATH in text:
|
||||||
|
continue
|
||||||
|
if len(text) > 200:
|
||||||
|
text = text[:200] + "…"
|
||||||
|
out[key] = text
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None:
|
||||||
|
"""Sentry ``before_send`` / ``before_send_log`` hook.
|
||||||
|
|
||||||
|
Recursively redacts the outgoing event. On *any* failure it returns ``None``
|
||||||
|
so the event is dropped rather than sent unscrubbed (fail closed for
|
||||||
|
redaction).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
return None
|
||||||
|
scrubbed = redact_value(event)
|
||||||
|
# Drop server_name if it leaked a hostname/path; PID is kept via tags.
|
||||||
|
scrubbed.pop("server_name", None)
|
||||||
|
return scrubbed
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event builders (pure, independently testable) ───────────────────────────
|
||||||
|
def build_blocker_event(
|
||||||
|
blocker_type: str,
|
||||||
|
*,
|
||||||
|
message: str | None = None,
|
||||||
|
next_action: str | None = None,
|
||||||
|
level: str = "warning",
|
||||||
|
tags: dict[str, Any] | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a redacted, structured Sentry event for a workflow blocker.
|
||||||
|
|
||||||
|
``next_action`` maps the issue's "canonical next action when available"
|
||||||
|
requirement (acceptance criterion 7).
|
||||||
|
"""
|
||||||
|
merged_tags = dict(tags or {})
|
||||||
|
merged_tags.setdefault("blocker_type", blocker_type)
|
||||||
|
safe_tags = build_tags(**merged_tags)
|
||||||
|
|
||||||
|
safe_extra = redact_value(dict(extra or {}))
|
||||||
|
if next_action:
|
||||||
|
# A short canonical next action is allowed (it is not a full prompt).
|
||||||
|
safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500])
|
||||||
|
|
||||||
|
event: dict[str, Any] = {
|
||||||
|
"message": redact_value(message or blocker_type),
|
||||||
|
"level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning",
|
||||||
|
"logger": "gitea-mcp.workflow",
|
||||||
|
"tags": safe_tags,
|
||||||
|
"extra": safe_extra,
|
||||||
|
"fingerprint": ["workflow-blocker", blocker_type],
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def build_checkin_payload(
|
||||||
|
monitor: str,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
check_in_id: str | None = None,
|
||||||
|
duration: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`.
|
||||||
|
|
||||||
|
``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an
|
||||||
|
explicit slug. Raises ``ValueError`` on an unknown status so callers cannot
|
||||||
|
silently send a malformed check-in.
|
||||||
|
"""
|
||||||
|
if status not in _CHECKIN_STATUSES:
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}"
|
||||||
|
)
|
||||||
|
slug = MONITOR_SLUGS.get(monitor, monitor)
|
||||||
|
payload: dict[str, Any] = {"monitor_slug": slug, "status": status}
|
||||||
|
if check_in_id:
|
||||||
|
payload["check_in_id"] = str(check_in_id)
|
||||||
|
if duration is not None:
|
||||||
|
try:
|
||||||
|
payload["duration"] = float(duration)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── Runtime init + capture (fail open) ──────────────────────────────────────
|
||||||
|
_STATE: dict[str, Any] = {"initialized": False, "config": None}
|
||||||
|
|
||||||
|
|
||||||
|
def is_initialized() -> bool:
|
||||||
|
return bool(_STATE.get("initialized"))
|
||||||
|
|
||||||
|
|
||||||
|
def active_config() -> SentryConfig | None:
|
||||||
|
return _STATE.get("config")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_for_tests() -> None:
|
||||||
|
"""Clear module init state. Test-only helper (never called in production)."""
|
||||||
|
_STATE["initialized"] = False
|
||||||
|
_STATE["config"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]:
|
||||||
|
"""Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present.
|
||||||
|
|
||||||
|
Idempotent and never raises. Returns an operator-safe status dict (no DSN
|
||||||
|
value). Behaviour is unchanged when the feature is off.
|
||||||
|
"""
|
||||||
|
cfg = config or load_config()
|
||||||
|
status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()}
|
||||||
|
try:
|
||||||
|
if not cfg.active:
|
||||||
|
status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)"
|
||||||
|
_STATE["config"] = cfg
|
||||||
|
return status
|
||||||
|
if not sdk_available():
|
||||||
|
status["reason"] = "sentry_sdk not installed"
|
||||||
|
_STATE["config"] = cfg
|
||||||
|
return status
|
||||||
|
|
||||||
|
init_kwargs: dict[str, Any] = {
|
||||||
|
"dsn": cfg.dsn,
|
||||||
|
"environment": cfg.environment,
|
||||||
|
"release": cfg.release,
|
||||||
|
"traces_sample_rate": cfg.traces_sample_rate,
|
||||||
|
"before_send": scrub_event,
|
||||||
|
"send_default_pii": False,
|
||||||
|
}
|
||||||
|
if cfg.enable_logs:
|
||||||
|
# sentry-sdk 2.x captures Python logs as structured logs when the
|
||||||
|
# experimental logs feature is enabled; scrub those too.
|
||||||
|
init_kwargs["_experiments"] = {
|
||||||
|
"enable_logs": True,
|
||||||
|
"before_send_log": scrub_event,
|
||||||
|
}
|
||||||
|
sentry_sdk.init(**init_kwargs) # type: ignore[union-attr]
|
||||||
|
_STATE["initialized"] = True
|
||||||
|
_STATE["config"] = cfg
|
||||||
|
status["initialized"] = True
|
||||||
|
status["reason"] = "sentry initialised"
|
||||||
|
except Exception as exc: # fail open: observability must not block startup
|
||||||
|
status["reason"] = f"init failed (ignored): {type(exc).__name__}"
|
||||||
|
_STATE["initialized"] = False
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None:
|
||||||
|
for key, val in tags.items():
|
||||||
|
try:
|
||||||
|
scope.set_tag(key, val)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def capture_workflow_blocker(
|
||||||
|
blocker_type: str,
|
||||||
|
*,
|
||||||
|
message: str | None = None,
|
||||||
|
next_action: str | None = None,
|
||||||
|
level: str = "warning",
|
||||||
|
tags: dict[str, Any] | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Capture a fail-closed workflow blocker as a structured Sentry event.
|
||||||
|
|
||||||
|
Always returns the redacted event dict (so callers/tests can inspect it),
|
||||||
|
and sends it to Sentry only when initialised. Fail open.
|
||||||
|
"""
|
||||||
|
event = build_blocker_event(
|
||||||
|
blocker_type,
|
||||||
|
message=message,
|
||||||
|
next_action=next_action,
|
||||||
|
level=level,
|
||||||
|
tags=tags,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if is_initialized() and sdk_available():
|
||||||
|
sentry_sdk.capture_event(event) # type: ignore[union-attr]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def capture_exception(
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
tags: dict[str, Any] | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Capture a runtime exception with scrubbed tags. Fail open.
|
||||||
|
|
||||||
|
Returns True only when the event was handed to an initialised SDK.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not (is_initialized() and sdk_available()):
|
||||||
|
return False
|
||||||
|
safe_tags = build_tags(**(tags or {}))
|
||||||
|
safe_extra = redact_value(dict(extra or {}))
|
||||||
|
with sentry_sdk.push_scope() as scope: # type: ignore[union-attr]
|
||||||
|
_set_scope_tags(scope, safe_tags)
|
||||||
|
for key, val in safe_extra.items():
|
||||||
|
try:
|
||||||
|
scope.set_extra(key, val)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sentry_sdk.capture_exception(exc) # type: ignore[union-attr]
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_checkin(
|
||||||
|
monitor: str,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
check_in_id: str | None = None,
|
||||||
|
duration: float | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Send a Sentry cron check-in for a watchdog job. Fail open.
|
||||||
|
|
||||||
|
Returns the payload (for inspection/tests), or ``None`` if the status was
|
||||||
|
invalid. Only transmits when initialised.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = build_checkin_payload(
|
||||||
|
monitor, status, check_in_id=check_in_id, duration=duration
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"):
|
||||||
|
sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return payload
|
||||||
@@ -33,22 +33,34 @@ Steps:
|
|||||||
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
||||||
2. Verify authenticated identity + active profile.
|
2. Verify authenticated identity + active profile.
|
||||||
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
||||||
4. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with
|
||||||
|
explicit `remote`/`org`/`repo`. Route on `recommended_next_action`:
|
||||||
|
- `merge_now` → continue merger path (do **not** update the branch)
|
||||||
|
- `update_branch_by_merge` → STOP; hand off to **author** for
|
||||||
|
`gitea_update_pr_branch_by_merge` (pins expected PR head + base head)
|
||||||
|
- `author_conflict_remediation` → STOP; author worktree conflict fix
|
||||||
|
- `fresh_review_required` → STOP; independent re-review at current head
|
||||||
|
- `blocked` → STOP and diagnose
|
||||||
|
Never merge on a former-head approval after update/remediation.
|
||||||
|
5. Capability evidence (#179): cite the exact gitea_resolve_task_capability
|
||||||
output (or runtime context) proving merge_pr is allowed — a bare
|
output (or runtime context) proving merge_pr is allowed — a bare
|
||||||
"capability checks passed" claim is downgraded.
|
"capability checks passed" claim is downgraded.
|
||||||
5. Final live-state recheck (#179), immediately before the merge mutation —
|
6. Final live-state recheck (#179), immediately before the merge mutation —
|
||||||
re-read the live PR and prove:
|
re-read the live PR and prove:
|
||||||
- PR still open
|
- PR still open
|
||||||
- live head SHA still equals the pinned/reviewed head SHA
|
- live head SHA still equals the pinned/reviewed head SHA
|
||||||
- base branch unchanged
|
- base branch unchanged
|
||||||
- no undismissed REQUEST_CHANGES / blocking review state remains
|
- no undismissed REQUEST_CHANGES / blocking review state remains
|
||||||
If any recheck fails → STOP, re-pin, re-validate.
|
If any recheck fails → STOP, re-pin, re-validate.
|
||||||
6. If any gate fails → STOP and report.
|
7. If any gate fails → STOP and report.
|
||||||
7. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
8. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||||
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
pinning the reviewed head SHA (expected_head_sha) and, where supported,
|
||||||
the changed-file set.
|
the changed-file set.
|
||||||
8. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
9. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
||||||
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
||||||
|
10. **Sequential queue:** after merge, refresh live `master`, run post-merge
|
||||||
|
cleanup handoff, then reassess the **next** PR (do not batch-update all
|
||||||
|
open PRs).
|
||||||
|
|
||||||
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup.
|
||||||
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
- Record merge mutations separately from cleanup mutations in the controller handoff.
|
||||||
|
|||||||
@@ -950,9 +950,53 @@ Final reports must state:
|
|||||||
* final live head SHA before merge
|
* final live head SHA before merge
|
||||||
* whether any push occurred during validation
|
* whether any push occurred during validation
|
||||||
|
|
||||||
|
## 26D. PR synchronization and conflict-remediation lifecycle
|
||||||
|
|
||||||
|
**Do not treat “approved” as the final readiness state.** For every approved
|
||||||
|
open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by
|
||||||
|
`recommended_next_action`:
|
||||||
|
|
||||||
|
| Action | Meaning | Next role / tools |
|
||||||
|
|--------|---------|-------------------|
|
||||||
|
| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch |
|
||||||
|
| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` |
|
||||||
|
| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase |
|
||||||
|
| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void |
|
||||||
|
| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update |
|
||||||
|
|
||||||
|
### Hard rules
|
||||||
|
|
||||||
|
* Pin both expected PR head and expected base head for every update. Either
|
||||||
|
race → fail closed with no partial mutation.
|
||||||
|
* Never rebase or force-push author branches.
|
||||||
|
* Never update an author branch from a reviewer or merger profile.
|
||||||
|
* Never preserve approval, reviewer lease, merger lease, or prepared verdict
|
||||||
|
across a head change.
|
||||||
|
* Process PRs **sequentially**: synchronize/remediate one → review new head →
|
||||||
|
merge → refresh live `master` → reassess the next PR. Do not update every
|
||||||
|
open PR at once (each merge restales the rest).
|
||||||
|
* Conflict remediation only in the existing dedicated issue/PR worktree under
|
||||||
|
`branches/`. Do not delete that worktree until the updated PR is merged and
|
||||||
|
cleanup eligibility is proven.
|
||||||
|
* Post-merge: canonical cleanup handoff to reconciler (section 28).
|
||||||
|
|
||||||
|
### After `gitea_update_pr_branch_by_merge` succeeds
|
||||||
|
|
||||||
|
1. Treat former-head approval as invalidated.
|
||||||
|
2. Release/supersede obsolete reviewer and merger leases.
|
||||||
|
3. Route `recommended_next_action=fresh_review_required` at the new head.
|
||||||
|
4. Independent re-review, then merger lease/adopt + merge at the new head only.
|
||||||
|
|
||||||
|
All Gitea reads/mutations use native MCP. Never substitute direct API, curl,
|
||||||
|
tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token
|
||||||
|
access.
|
||||||
|
|
||||||
## 27. Merge rules
|
## 27. Merge rules
|
||||||
|
|
||||||
Before merge, rerun fresh live checks:
|
Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open
|
||||||
|
and may be behind base. Only proceed with merge when
|
||||||
|
`recommended_next_action` is `merge_now` and approval remains at the current
|
||||||
|
head. Then rerun fresh live checks:
|
||||||
|
|
||||||
* whoami
|
* whoami
|
||||||
* active profile/runtime
|
* active profile/runtime
|
||||||
|
|||||||
@@ -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 []),
|
||||||
|
}
|
||||||
+706
-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:
|
||||||
@@ -438,3 +488,646 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
|||||||
"This path only clears a lock when the referenced PR is merged/closed.",
|
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||||
]
|
]
|
||||||
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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def has_unresolved_terminal_evidence(lock: dict | None) -> bool:
|
||||||
|
"""True when *lock* still carries a terminal live mutation ledger."""
|
||||||
|
return last_terminal_mutation(lock) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_init_overwrite(
|
||||||
|
existing_lock: dict | None,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Whether init_review_decision_lock may replace an existing durable lock (#709 AC2).
|
||||||
|
|
||||||
|
Unresolved terminal evidence must never be silently replaced by an empty
|
||||||
|
initialized lock. *force* does not authorize destruction of terminal
|
||||||
|
ledgers — only explicit cleanup / archive transitions may remove them.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"overwrite_allowed": True,
|
||||||
|
"has_existing": existing_lock is not None,
|
||||||
|
"has_unresolved_terminal": False,
|
||||||
|
"reasons": [],
|
||||||
|
"last_terminal_pr": None,
|
||||||
|
"last_terminal_action": None,
|
||||||
|
"existing_profile_identity": None,
|
||||||
|
}
|
||||||
|
if existing_lock is None:
|
||||||
|
result["reasons"].append("no existing lock; empty init allowed")
|
||||||
|
return result
|
||||||
|
result["existing_profile_identity"] = (
|
||||||
|
existing_lock.get("profile_identity")
|
||||||
|
or existing_lock.get("session_profile_lock")
|
||||||
|
or existing_lock.get("session_profile")
|
||||||
|
)
|
||||||
|
last = last_terminal_mutation(existing_lock)
|
||||||
|
if last is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
"existing lock has no terminal mutations; re-init allowed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["has_unresolved_terminal"] = True
|
||||||
|
result["overwrite_allowed"] = False
|
||||||
|
result["last_terminal_pr"] = last.get("pr_number")
|
||||||
|
result["last_terminal_action"] = last.get("action")
|
||||||
|
result["reasons"].append(
|
||||||
|
"refuse empty re-init: unresolved terminal decision-lock evidence "
|
||||||
|
f"present ({last.get('action')} on PR #{last.get('pr_number')}); "
|
||||||
|
"use gitea_cleanup_stale_review_decision_lock when moot, or archive "
|
||||||
|
f"via sanctioned recovery — force={force!r} does not authorize overwrite "
|
||||||
|
"(#709 AC2)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def lock_targets_merged_pr_approval(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when *lock*'s last terminal mutation is approve of *pr_number*.
|
||||||
|
|
||||||
|
When *expected_head_sha* is provided, a **recorded** terminal head must
|
||||||
|
match. Legacy same-repo approve locks with no recorded head do **not**
|
||||||
|
match (fail closed, #709 F3 residual / review 435) — callers must use the
|
||||||
|
strict secondary path that refuses no-head clears.
|
||||||
|
"""
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
return False
|
||||||
|
if last.get("action") != "approve":
|
||||||
|
return False
|
||||||
|
if last.get("pr_number") != pr_number:
|
||||||
|
return False
|
||||||
|
if expected_head_sha:
|
||||||
|
want = normalize_head_sha(expected_head_sha)
|
||||||
|
if not want:
|
||||||
|
return False
|
||||||
|
locked = mutation_head_sha(last, lock)
|
||||||
|
# Require recorded-head match; unrecorded head is not a match (#709 F3).
|
||||||
|
if not locked or not heads_equal(locked, want):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_post_merge_recovery_record(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
head_sha: str | None,
|
||||||
|
merge_commit_sha: str | None,
|
||||||
|
target_profile_identity: str | None,
|
||||||
|
failed_step: str,
|
||||||
|
error: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
org: str | None,
|
||||||
|
repo: str | None,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Durable recovery-required payload after irreversible merge (#709 AC3)."""
|
||||||
|
return {
|
||||||
|
"event": "post_merge_decision_lock_recovery_required",
|
||||||
|
"status": "recovery_required",
|
||||||
|
"issue_ref": "#709",
|
||||||
|
"recovery_critical": True,
|
||||||
|
"applied": False, # never claim historical cleanup succeeded
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_sha": normalize_head_sha(head_sha),
|
||||||
|
"merge_commit_sha": merge_commit_sha,
|
||||||
|
"target_profile_identity": target_profile_identity,
|
||||||
|
"failed_step": failed_step,
|
||||||
|
"error": error,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"required_recovery_action": (
|
||||||
|
"retry cross-profile decision-lock cleanup and audit publication "
|
||||||
|
"for the merged PR; if terminal evidence is gone, use "
|
||||||
|
"gitea_record_irrecoverable_decision_lock_provenance"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_irrecoverable_provenance_record(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
head_sha: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
org: str | None,
|
||||||
|
repo: str | None,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
reason: str,
|
||||||
|
incident_ref: str | None = None,
|
||||||
|
# Deprecated kwargs retained only so stale call sites fail closed:
|
||||||
|
operator_authorized: bool | None = None,
|
||||||
|
# Required for merger-acceptable records (#709 F1):
|
||||||
|
authorization: dict[str, Any] | None = None,
|
||||||
|
incident_issue: int | None = None,
|
||||||
|
incident_comment_id: int | None = None,
|
||||||
|
destroyed_subject: str | None = None,
|
||||||
|
historical_provenance_subject: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Truthful absence-of-proof record (#709 AC5). Never sets applied=True.
|
||||||
|
|
||||||
|
Caller-supplied ``operator_authorized`` is **ignored** as authorization
|
||||||
|
evidence (review 434 F1). Prefer
|
||||||
|
:func:`irrecoverable_provenance.build_irrecoverable_provenance_record`
|
||||||
|
with a verified server-side authorization artifact.
|
||||||
|
"""
|
||||||
|
# Explicitly ignore deprecated self-assertable Boolean.
|
||||||
|
_ = operator_authorized
|
||||||
|
if authorization is not None and incident_issue is not None and incident_comment_id is not None:
|
||||||
|
from irrecoverable_provenance import (
|
||||||
|
build_irrecoverable_provenance_record as _build,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build(
|
||||||
|
pr_number=int(pr_number),
|
||||||
|
head_sha=str(head_sha or ""),
|
||||||
|
remote=str(remote or ""),
|
||||||
|
org=str(org or ""),
|
||||||
|
repo=str(repo or ""),
|
||||||
|
actor_username=actor_username,
|
||||||
|
profile_name=profile_name,
|
||||||
|
reason=reason,
|
||||||
|
incident_issue=int(incident_issue),
|
||||||
|
incident_comment_id=int(incident_comment_id),
|
||||||
|
authorization=authorization,
|
||||||
|
destroyed_subject=destroyed_subject,
|
||||||
|
historical_provenance_subject=historical_provenance_subject
|
||||||
|
or destroyed_subject,
|
||||||
|
)
|
||||||
|
# Fail-closed skeleton when no server authorization is supplied: never
|
||||||
|
# sets merger_may_accept True (even if operator_authorized was True).
|
||||||
|
return {
|
||||||
|
"event": "irrecoverable_decision_lock_provenance",
|
||||||
|
"status": "provenance_irrecoverable",
|
||||||
|
"record_type": "irrecoverable_decision_provenance",
|
||||||
|
"operator_recovery_required": True,
|
||||||
|
"issue_ref": "#709",
|
||||||
|
"recovery_critical": True,
|
||||||
|
"applied": False,
|
||||||
|
"historical_cleanup_proven": False,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_sha": normalize_head_sha(head_sha),
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"reason": reason,
|
||||||
|
"incident_ref": incident_ref,
|
||||||
|
"incident_issue": incident_issue,
|
||||||
|
"incident_comment_id": incident_comment_id,
|
||||||
|
"authorization_verified": False,
|
||||||
|
"merger_may_accept": False,
|
||||||
|
"acceptance_rule": (
|
||||||
|
"Merger may accept this record only when a server-side "
|
||||||
|
"authorization artifact verifies for remote/org/repo/PR/exact "
|
||||||
|
"head/incident, the record is durable and read back, and normal "
|
||||||
|
"merge gates still pass. Caller Booleans never authorize. This "
|
||||||
|
"does not prove historical cleanup."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for irrecoverable provenance audit (no applied=true claim)."""
|
||||||
|
from irrecoverable_provenance import (
|
||||||
|
format_irrecoverable_audit_comment as _fmt,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _fmt(record)
|
||||||
|
|
||||||
|
|
||||||
|
def format_post_merge_recovery_comment(record: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for post-merge recovery-required audit."""
|
||||||
|
lines = [
|
||||||
|
"## Post-merge decision-lock recovery required (#709)",
|
||||||
|
"",
|
||||||
|
"Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)",
|
||||||
|
"",
|
||||||
|
f"- actor: `{record.get('actor_username')}`",
|
||||||
|
f"- profile: `{record.get('profile_name')}`",
|
||||||
|
f"- timestamp: `{record.get('timestamp')}`",
|
||||||
|
f"- PR: `#{record.get('pr_number')}`",
|
||||||
|
f"- head_sha: `{record.get('head_sha')}`",
|
||||||
|
f"- merge_commit_sha: `{record.get('merge_commit_sha')}`",
|
||||||
|
f"- target_profile_identity: `{record.get('target_profile_identity')}`",
|
||||||
|
f"- failed_step: `{record.get('failed_step')}`",
|
||||||
|
f"- error: `{record.get('error')}`",
|
||||||
|
"",
|
||||||
|
f"Required action: {record.get('required_recovery_action')}",
|
||||||
|
"",
|
||||||
|
"applied=false — do not treat this as successful cleanup evidence.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
+95
-1
@@ -60,18 +60,57 @@ 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",
|
||||||
},
|
},
|
||||||
|
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
||||||
|
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
||||||
|
"assess_pr_sync_status": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_assess_pr_sync_status": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"update_pr_branch_by_merge": {
|
||||||
|
"permission": "gitea.branch.push",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_update_pr_branch_by_merge": {
|
||||||
|
"permission": "gitea.branch.push",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"review_pr": {
|
"review_pr": {
|
||||||
"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": {
|
||||||
@@ -84,7 +123,19 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
},
|
},
|
||||||
"adopt_merger_pr_lease": {
|
"adopt_merger_pr_lease": {
|
||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "reviewer",
|
"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).
|
||||||
@@ -127,6 +178,49 @@ 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).
|
||||||
|
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
||||||
|
"issue_irrecoverable_provenance_authorization": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_issue_irrecoverable_provenance_authorization": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"consume_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.pr.merge",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
|
"gitea_consume_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.pr.merge",
|
||||||
|
"role": "merger",
|
||||||
|
},
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -79,41 +79,46 @@ class TestApiRequestFailures(unittest.TestCase):
|
|||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
||||||
mock_open.side_effect = TimeoutError("timed out")
|
mock_open.side_effect = TimeoutError("timed out")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
# Fixed message only (#699) — still a RuntimeError subclass.
|
||||||
|
self.assertIsInstance(ctx.exception, RuntimeError)
|
||||||
|
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_dns_network_failure_converted(self, mock_open):
|
def test_dns_network_failure_converted(self, mock_open):
|
||||||
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_502_upstream_message(self, mock_open):
|
def test_502_upstream_message(self, mock_open):
|
||||||
mock_open.side_effect = http_error(502, "bad gateway")
|
mock_open.side_effect = http_error(502, "bad gateway")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("HTTP 502", msg)
|
self.assertEqual(msg, "Gitea upstream unavailable")
|
||||||
self.assertIn("upstream unavailable", msg)
|
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
|
||||||
|
self.assertEqual(ctx.exception.http_status, 502)
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_503_upstream_message(self, mock_open):
|
def test_503_upstream_message(self, mock_open):
|
||||||
mock_open.side_effect = http_error(503, "")
|
mock_open.side_effect = http_error(503, "")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("HTTP 503", str(ctx.exception))
|
self.assertEqual(str(ctx.exception), "Gitea upstream unavailable")
|
||||||
self.assertIn("upstream unavailable", str(ctx.exception))
|
self.assertEqual(ctx.exception.http_status, 503)
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
||||||
# Non-JSON garbage error body must still yield a clean RuntimeError.
|
# Non-JSON garbage error body must still yield a clean typed error
|
||||||
|
# with a fixed message (no body echo — #699).
|
||||||
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertIn("HTTP 500", str(ctx.exception))
|
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||||
|
self.assertNotIn("<html>", str(ctx.exception))
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
||||||
@@ -126,16 +131,17 @@ class TestApiRequestFailures(unittest.TestCase):
|
|||||||
def test_no_secret_leak_in_error_body(self, mock_open):
|
def test_no_secret_leak_in_error_body(self, mock_open):
|
||||||
mock_open.side_effect = http_error(
|
mock_open.side_effect = http_error(
|
||||||
400, "failed: token supersecret123 rejected")
|
400, "failed: token supersecret123 rejected")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertNotIn("supersecret123", msg)
|
self.assertNotIn("supersecret123", msg)
|
||||||
self.assertIn(gitea_audit.REDACTED, msg)
|
# Fixed message — body never appears (stronger than redaction).
|
||||||
|
self.assertEqual(msg, "Gitea HTTP request failed")
|
||||||
|
|
||||||
@patch("gitea_auth.urllib.request.urlopen")
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
def test_auth_header_never_in_error(self, mock_open):
|
def test_auth_header_never_in_error(self, mock_open):
|
||||||
mock_open.side_effect = http_error(400, "bad request")
|
mock_open.side_effect = http_error(400, "bad request")
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ from task_capability_map import required_permission, required_role
|
|||||||
|
|
||||||
DELETE_PROFILE = {
|
DELETE_PROFILE = {
|
||||||
"profile_name": "prgs-author-delete",
|
"profile_name": "prgs-author-delete",
|
||||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
"role": "author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"audit_label": "prgs-author-delete",
|
"audit_label": "prgs-author-delete",
|
||||||
}
|
}
|
||||||
|
|||||||
+1100
-21
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,10 @@ HEAD_C = "c" * 40
|
|||||||
|
|
||||||
|
|
||||||
def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None):
|
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()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,499 @@
|
|||||||
|
"""#720: Expired old-head KIND_DECISION_LOCK must not block fresh review.
|
||||||
|
|
||||||
|
Reproduction shape (PR #616):
|
||||||
|
* REQUEST_CHANGES terminal at head A
|
||||||
|
* open PR advanced to head B
|
||||||
|
* durable decision lock age > default 4h TTL
|
||||||
|
* fresh_review_on_current_head_allowed is true but unreachable under TTL-first reject
|
||||||
|
* mark_final fails with "session state expired after 4h"
|
||||||
|
* assessment may report "no lock" while the file remains on disk
|
||||||
|
|
||||||
|
Security invariants preserved:
|
||||||
|
* same-head second terminal remains fail-closed (#332/#620)
|
||||||
|
* REQUEST_CHANGES is never treated as approval
|
||||||
|
* historical mutations remain on the ledger
|
||||||
|
* merged/closed moot cleanup still allowed (#594)
|
||||||
|
* irrecoverable provenance still requires #709 authorization
|
||||||
|
* ordinary profiles do not gain gitea.decision_lock.irrecoverable_recovery
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
import mcp_session_state as ss
|
||||||
|
import mcp_server
|
||||||
|
import stale_review_decision_lock as srdl
|
||||||
|
import task_capability_map
|
||||||
|
|
||||||
|
HEAD_A = "a0fffae576673ba7df7456b32e6aec916581bdfb"
|
||||||
|
HEAD_B = "a6a2243aad9c3e385fc70509f4941a0f0ec33162"
|
||||||
|
HEAD_SAME = HEAD_A
|
||||||
|
|
||||||
|
RC_616_A = {
|
||||||
|
"pr_number": 616,
|
||||||
|
"action": "request_changes",
|
||||||
|
"review_id": 443,
|
||||||
|
"review_state": "request_changes",
|
||||||
|
"head_sha": HEAD_A,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(mutations=None, **kwargs):
|
||||||
|
base = {
|
||||||
|
"task": "review_pr",
|
||||||
|
"kind": ss.KIND_DECISION_LOCK,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"session_pid": os.getpid(),
|
||||||
|
"session_profile": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"final_review_decision_ready": False,
|
||||||
|
"ready_pr_number": kwargs.get("ready_pr"),
|
||||||
|
"ready_action": kwargs.get("ready_action"),
|
||||||
|
"ready_expected_head_sha": kwargs.get("ready_head"),
|
||||||
|
"ready_remote": "prgs" if kwargs.get("ready_pr") else None,
|
||||||
|
"ready_org": "Scaled-Tech-Consulting" if kwargs.get("ready_pr") else None,
|
||||||
|
"ready_repo": "Gitea-Tools" if kwargs.get("ready_pr") else None,
|
||||||
|
"live_mutations": list(mutations or []),
|
||||||
|
"correction_authorized": False,
|
||||||
|
"correction_reason": None,
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _open_pr(pr_number=616, head=HEAD_B, merged=False, closed=False):
|
||||||
|
state = "closed" if closed or merged else "open"
|
||||||
|
return {
|
||||||
|
"number": pr_number,
|
||||||
|
"state": state,
|
||||||
|
"merged": merged,
|
||||||
|
"merged_at": "2026-07-16T12:00:00Z" if merged else None,
|
||||||
|
"merge_commit_sha": "m" * 40 if merged else None,
|
||||||
|
"head": {"sha": head},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _no_lease():
|
||||||
|
return {"block": False, "reasons": [], "mutation_allowed": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _feedback(blocking=False, stale=True):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"has_blocking_change_requests": blocking,
|
||||||
|
"review_feedback_stale": stale,
|
||||||
|
"current_head_sha": HEAD_B,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _age_lock_payload(lock: dict, hours: float = 5.0) -> dict:
|
||||||
|
"""Rewrite timestamps to *hours* ago (simulates durable age without touching prod)."""
|
||||||
|
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
out = dict(lock)
|
||||||
|
out["recorded_at"] = aged
|
||||||
|
out["updated_at"] = aged
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue720ExpiredDecisionLockLifecycle(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
ss.STATE_DIR_ENV: self._tmp.name,
|
||||||
|
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-reviewer",
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self.env.start()
|
||||||
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
|
import review_workflow_load
|
||||||
|
|
||||||
|
review_workflow_load.clear_review_workflow_load()
|
||||||
|
review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT)
|
||||||
|
mcp_server.gitea_load_review_workflow()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
|
import review_workflow_load
|
||||||
|
|
||||||
|
review_workflow_load.clear_review_workflow_load()
|
||||||
|
self.env.stop()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _persist_aged_lock(self, mutations, hours: float = 5.0, **kwargs):
|
||||||
|
"""Write decision lock aged > TTL to the temp durable store (test-only)."""
|
||||||
|
payload = _age_lock_payload(_lock(mutations, **kwargs), hours=hours)
|
||||||
|
saved = ss.save_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
payload=payload,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
# save_state refreshes updated_at; re-age the on-disk envelope for TTL tests.
|
||||||
|
path = ss.state_file_path(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self._tmp.name,
|
||||||
|
)
|
||||||
|
aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
envelope = json.load(fh)
|
||||||
|
envelope["recorded_at"] = aged
|
||||||
|
envelope["updated_at"] = aged
|
||||||
|
if isinstance(envelope.get("payload"), dict):
|
||||||
|
envelope["payload"]["recorded_at"] = aged
|
||||||
|
envelope["payload"]["updated_at"] = aged
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(envelope, fh, indent=2, sort_keys=True)
|
||||||
|
fh.write("\n")
|
||||||
|
# Drop memory so subsequent loads hit durable store.
|
||||||
|
mcp_server._REVIEW_DECISION_LOCK = None
|
||||||
|
return saved
|
||||||
|
|
||||||
|
def _mark(self, pr, action, head, feedback=None):
|
||||||
|
with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch(
|
||||||
|
"mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease()
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_get_pr_review_feedback",
|
||||||
|
return_value=feedback or _feedback(blocking=False, stale=True),
|
||||||
|
), patch.object(
|
||||||
|
mcp_server,
|
||||||
|
"gitea_check_pr_eligibility",
|
||||||
|
return_value={"eligible": True, "head_sha": head},
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.mcp_daemon_guard,
|
||||||
|
"assert_sanctioned_mutation_runtime",
|
||||||
|
return_value=None,
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.mcp_daemon_guard,
|
||||||
|
"assert_no_direct_import_bypass",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
return mcp_server.gitea_mark_final_review_decision(
|
||||||
|
pr_number=pr,
|
||||||
|
action=action,
|
||||||
|
expected_head_sha=head,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- AC regression matrix ---
|
||||||
|
|
||||||
|
def test_under_four_hours_fresh_review_at_b_allowed(self):
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=1.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(616, "approve", HEAD_B)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
|
||||||
|
def test_over_four_hours_fresh_review_at_b_still_allowed(self):
|
||||||
|
"""Primary #720 defect: age > TTL must not block head-B mark_final."""
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=5.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
# Prove durable load is possible for recovery-critical decision locks.
|
||||||
|
loaded = ss.load_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
loaded,
|
||||||
|
"expired KIND_DECISION_LOCK must remain loadable (not generic TTL cache)",
|
||||||
|
)
|
||||||
|
res = self._mark(616, "approve", HEAD_B)
|
||||||
|
self.assertTrue(
|
||||||
|
res.get("marked_ready"),
|
||||||
|
f"expected mark_ready on head B after >4h; got {res}",
|
||||||
|
)
|
||||||
|
reasons = " ".join(res.get("reasons") or [])
|
||||||
|
self.assertNotIn("session state expired", reasons)
|
||||||
|
|
||||||
|
def test_historical_review_at_a_preserved_and_stale(self):
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=5.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(616, "approve", HEAD_B)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
self.assertIsNotNone(lock)
|
||||||
|
hist = [m for m in lock["live_mutations"] if m.get("review_id") == 443]
|
||||||
|
self.assertEqual(len(hist), 1)
|
||||||
|
self.assertEqual(hist[0]["head_sha"], HEAD_A)
|
||||||
|
self.assertEqual(hist[0]["action"], "request_changes")
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
lock, pr_live=_open_pr(616, HEAD_B)
|
||||||
|
)
|
||||||
|
self.assertTrue(a["stale_by_head"])
|
||||||
|
self.assertTrue(a["fresh_review_on_current_head_allowed"])
|
||||||
|
self.assertEqual(a["locked_head_sha"], HEAD_A)
|
||||||
|
|
||||||
|
def test_no_reuse_approval_from_head_a(self):
|
||||||
|
"""REQUEST_CHANGES at A is never an approval credential for B."""
|
||||||
|
self._persist_aged_lock([RC_616_A], hours=5.0)
|
||||||
|
lock = ss.load_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||||
|
)
|
||||||
|
last = srdl.last_terminal_mutation(lock)
|
||||||
|
self.assertEqual(last.get("action"), "request_changes")
|
||||||
|
self.assertNotEqual(last.get("action"), "approve")
|
||||||
|
# Gate must still require a new mark/submit for head B; prior RC is not approve.
|
||||||
|
reasons = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
616, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
)
|
||||||
|
self.assertEqual(reasons, [])
|
||||||
|
|
||||||
|
def test_second_terminal_mutation_at_b_same_run_blocked(self):
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=5.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(616, "approve", HEAD_B)
|
||||||
|
self.assertTrue(res.get("marked_ready"), res)
|
||||||
|
# Record terminal approve at B (same run).
|
||||||
|
lock = mcp_server._load_review_decision_lock()
|
||||||
|
lock["final_review_decision_ready"] = True
|
||||||
|
lock["ready_pr_number"] = 616
|
||||||
|
lock["ready_action"] = "approve"
|
||||||
|
lock["ready_expected_head_sha"] = HEAD_B
|
||||||
|
mcp_server._save_review_decision_lock(lock)
|
||||||
|
mcp_server.record_live_review_mutation(616, "approve", review_id=999)
|
||||||
|
# Second terminal at same head B must hard-stop.
|
||||||
|
hard = mcp_server.terminal_review_hard_stop_reasons(
|
||||||
|
616, "mark_ready", expected_head_sha=HEAD_B
|
||||||
|
)
|
||||||
|
self.assertTrue(hard)
|
||||||
|
res2 = self._mark(616, "approve", HEAD_B)
|
||||||
|
self.assertFalse(res2.get("marked_ready"))
|
||||||
|
|
||||||
|
def test_open_pr_same_head_expired_still_fail_closed(self):
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=5.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(616, "approve", HEAD_SAME)
|
||||||
|
self.assertFalse(res.get("marked_ready"), res)
|
||||||
|
self.assertTrue(
|
||||||
|
any("#332" in r or "already consumed" in r for r in (res.get("reasons") or [])),
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merged_pr_moot_cleanup_still_allowed(self):
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=5.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
lock = ss.load_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||||
|
)
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
lock, pr_live=_open_pr(616, HEAD_A, merged=True)
|
||||||
|
)
|
||||||
|
self.assertTrue(a["is_moot"])
|
||||||
|
self.assertTrue(a["cleanup_allowed"])
|
||||||
|
self.assertFalse(a["fresh_review_on_current_head_allowed"])
|
||||||
|
|
||||||
|
def test_assessment_reports_expired_old_head_not_absent(self):
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=5.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
# Disk presence + load after lifecycle fix.
|
||||||
|
path = ss.state_file_path(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self._tmp.name,
|
||||||
|
)
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
loaded = ss.load_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
inspect = ss.inspect_state_envelope(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self._tmp.name,
|
||||||
|
)
|
||||||
|
self.assertTrue(inspect.get("on_disk"))
|
||||||
|
self.assertTrue(inspect.get("has_payload"))
|
||||||
|
self.assertTrue(inspect.get("recovery_critical") or inspect.get("ttl_exempt"))
|
||||||
|
self.assertTrue(inspect.get("age_hours", 0) >= 4.0)
|
||||||
|
a = srdl.assess_stale_review_decision_lock(
|
||||||
|
loaded, pr_live=_open_pr(616, HEAD_B)
|
||||||
|
)
|
||||||
|
self.assertTrue(a["has_lock"])
|
||||||
|
self.assertNotIn("no review decision lock present", " ".join(a["reasons"]))
|
||||||
|
self.assertTrue(a["stale_by_head"])
|
||||||
|
self.assertEqual(a["last_terminal_action"], "request_changes")
|
||||||
|
|
||||||
|
def test_existing_serialized_records_compatible(self):
|
||||||
|
"""Pre-fix ledgers without recovery_critical flag still load via kind."""
|
||||||
|
payload = _age_lock_payload(_lock([RC_616_A]), hours=8.0)
|
||||||
|
payload.pop("recovery_critical", None)
|
||||||
|
# Manually write pre-#720-shaped envelope (kind only on envelope).
|
||||||
|
path = ss.state_file_path(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self._tmp.name,
|
||||||
|
)
|
||||||
|
import json
|
||||||
|
|
||||||
|
os.makedirs(self._tmp.name, exist_ok=True)
|
||||||
|
aged = payload["recorded_at"]
|
||||||
|
envelope = {
|
||||||
|
"kind": ss.KIND_DECISION_LOCK,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
"recorded_at": aged,
|
||||||
|
"updated_at": aged,
|
||||||
|
"writer_pid": os.getpid(),
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(envelope, fh, indent=2, sort_keys=True)
|
||||||
|
fh.write("\n")
|
||||||
|
loaded = ss.load_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
self.assertEqual(loaded["live_mutations"][0]["review_id"], 443)
|
||||||
|
|
||||||
|
def test_decision_lock_is_recovery_critical_kind(self):
|
||||||
|
self.assertIn(ss.KIND_DECISION_LOCK, ss.RECOVERY_CRITICAL_KINDS)
|
||||||
|
|
||||||
|
def test_workflow_load_still_ttl_expires(self):
|
||||||
|
"""Do not make every session-state kind permanently TTL-exempt."""
|
||||||
|
aged = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat().replace(
|
||||||
|
"+00:00", "Z"
|
||||||
|
)
|
||||||
|
rec = {
|
||||||
|
"kind": ss.KIND_WORKFLOW_LOAD,
|
||||||
|
"recorded_at": aged,
|
||||||
|
"updated_at": aged,
|
||||||
|
"profile_identity": "prgs-reviewer",
|
||||||
|
"session_profile_lock": "prgs-reviewer",
|
||||||
|
}
|
||||||
|
reasons = ss.identity_match_reasons(
|
||||||
|
rec, profile_identity="prgs-reviewer"
|
||||||
|
)
|
||||||
|
self.assertTrue(any("expired" in r for r in reasons), reasons)
|
||||||
|
|
||||||
|
def test_irrecoverable_permission_not_on_ordinary_profiles(self):
|
||||||
|
perm = "gitea.decision_lock.irrecoverable_recovery"
|
||||||
|
# Capability map must not map ordinary author/reviewer/merger tasks to it.
|
||||||
|
for task in (
|
||||||
|
"create_issue",
|
||||||
|
"comment_issue",
|
||||||
|
"review_pr",
|
||||||
|
"merge_pr",
|
||||||
|
"lock_issue",
|
||||||
|
"create_pr",
|
||||||
|
):
|
||||||
|
req = task_capability_map.required_permission(task)
|
||||||
|
self.assertNotEqual(req, perm, msg=task)
|
||||||
|
|
||||||
|
def test_structured_error_when_same_head_expired(self):
|
||||||
|
self._persist_aged_lock(
|
||||||
|
[RC_616_A],
|
||||||
|
hours=5.0,
|
||||||
|
ready_pr=616,
|
||||||
|
ready_head=HEAD_A,
|
||||||
|
ready_action="request_changes",
|
||||||
|
)
|
||||||
|
res = self._mark(616, "approve", HEAD_A)
|
||||||
|
self.assertFalse(res.get("marked_ready"))
|
||||||
|
reasons = res.get("reasons") or []
|
||||||
|
self.assertTrue(reasons)
|
||||||
|
blob = " ".join(reasons)
|
||||||
|
# Actionable recovery text from hard-stop (not generic internal_error).
|
||||||
|
self.assertIn("#332", blob)
|
||||||
|
self.assertTrue(
|
||||||
|
"#620" in blob or "head moved" in blob or "new expected_head_sha" in blob
|
||||||
|
or "already consumed" in blob
|
||||||
|
)
|
||||||
|
self.assertNotIn("internal_error", blob.lower())
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue720InspectEnvelope(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
ss.STATE_DIR_ENV: self._tmp.name,
|
||||||
|
ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self.env.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.env.stop()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_inspect_missing_file(self):
|
||||||
|
info = ss.inspect_state_envelope(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self._tmp.name,
|
||||||
|
)
|
||||||
|
self.assertFalse(info["on_disk"])
|
||||||
|
self.assertFalse(info["has_payload"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1446,10 +1446,16 @@ class TestReviewPR(unittest.TestCase):
|
|||||||
class TestDeleteBranch(unittest.TestCase):
|
class TestDeleteBranch(unittest.TestCase):
|
||||||
|
|
||||||
DELETE_PROFILE = {
|
DELETE_PROFILE = {
|
||||||
"profile_name": "test-deleter",
|
"profile_name": "test-author-deleter",
|
||||||
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
"role": "author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"audit_label": "test-deleter",
|
"audit_label": "test-author-deleter",
|
||||||
}
|
}
|
||||||
|
|
||||||
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||||
@@ -2561,15 +2567,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)
|
||||||
|
with patch("mcp_server.api_request", return_value={"id": 9001}):
|
||||||
r = gitea_authorize_review_correction(
|
r = gitea_authorize_review_correction(
|
||||||
prior_review_id=42,
|
prior_review_id=42,
|
||||||
prior_review_state="approve",
|
prior_review_state="approve",
|
||||||
reason="typo",
|
reason="typo",
|
||||||
operator_authorized=True,
|
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
|
||||||
@@ -2583,6 +2592,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"]))
|
||||||
@@ -2593,10 +2603,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 {}
|
||||||
@@ -2609,6 +2631,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"]))
|
||||||
@@ -2666,6 +2689,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(
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -92,14 +92,19 @@ class TestMigrateProfiles(unittest.TestCase):
|
|||||||
author = prgs_gitea["identities"]["author"]
|
author = prgs_gitea["identities"]["author"]
|
||||||
self.assertEqual(author["username"], "jcwalker3")
|
self.assertEqual(author["username"], "jcwalker3")
|
||||||
self.assertEqual(author["auth"]["id"], "redacted-author-ref")
|
self.assertEqual(author["auth"]["id"], "redacted-author-ref")
|
||||||
self.assertEqual(author["allowed_operations"], ["read", "comment"])
|
self.assertEqual(
|
||||||
self.assertEqual(author["forbidden_operations"], ["approve", "merge"])
|
author["allowed_operations"], ["gitea.read", "gitea.pr.comment"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
author["forbidden_operations"],
|
||||||
|
["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
|
)
|
||||||
|
|
||||||
reviewer = prgs_gitea["identities"]["reviewer"]
|
reviewer = prgs_gitea["identities"]["reviewer"]
|
||||||
self.assertEqual(reviewer["role"], "reviewer")
|
self.assertEqual(reviewer["role"], "reviewer")
|
||||||
self.assertEqual(reviewer["username"], "sysadmin")
|
self.assertEqual(reviewer["username"], "sysadmin")
|
||||||
self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref")
|
self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref")
|
||||||
self.assertIn("merge", reviewer["allowed_operations"])
|
self.assertIn("gitea.pr.merge", reviewer["allowed_operations"])
|
||||||
|
|
||||||
def test_alias_generation(self):
|
def test_alias_generation(self):
|
||||||
"""Test that aliases are correctly generated to support old profile names."""
|
"""Test that aliases are correctly generated to support old profile names."""
|
||||||
@@ -188,7 +193,7 @@ class TestMigrateProfiles(unittest.TestCase):
|
|||||||
self.assertNotIn("token", stdout_output.lower())
|
self.assertNotIn("token", stdout_output.lower())
|
||||||
|
|
||||||
def test_explicit_operations_are_preserved(self):
|
def test_explicit_operations_are_preserved(self):
|
||||||
"""Explicit v1 permissions must not be replaced by role defaults."""
|
"""Explicit v1 permissions are canonicalized, not replaced by role defaults."""
|
||||||
v1_data = json.loads(json.dumps(self.v1_content))
|
v1_data = json.loads(json.dumps(self.v1_content))
|
||||||
v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"]
|
v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"]
|
||||||
v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"]
|
v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"]
|
||||||
@@ -198,8 +203,8 @@ class TestMigrateProfiles(unittest.TestCase):
|
|||||||
v2_data["environments"]["prgs"]["services"]["gitea"]
|
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||||
["identities"]["reviewer"]
|
["identities"]["reviewer"]
|
||||||
)
|
)
|
||||||
self.assertEqual(reviewer["allowed_operations"], ["read"])
|
self.assertEqual(reviewer["allowed_operations"], ["gitea.read"])
|
||||||
self.assertEqual(reviewer["forbidden_operations"], ["merge"])
|
self.assertEqual(reviewer["forbidden_operations"], ["gitea.pr.merge"])
|
||||||
|
|
||||||
def test_inferred_role_defaults_only_when_unambiguous(self):
|
def test_inferred_role_defaults_only_when_unambiguous(self):
|
||||||
"""Role defaults are allowed only for clear author/reviewer profiles."""
|
"""Role defaults are allowed only for clear author/reviewer profiles."""
|
||||||
@@ -306,6 +311,171 @@ class TestMigrateProfiles(unittest.TestCase):
|
|||||||
migrate_profiles.main()
|
migrate_profiles.main()
|
||||||
self.assertEqual(cm.exception.code, 1)
|
self.assertEqual(cm.exception.code, 1)
|
||||||
|
|
||||||
|
def test_reconciler_profile_migration(self):
|
||||||
|
"""Legacy reconciler shorthands migrate to valid canonical operations."""
|
||||||
|
import gitea_config
|
||||||
|
import reconciler_profile
|
||||||
|
|
||||||
|
v1_data = {
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {
|
||||||
|
"prgs-reconciler": {
|
||||||
|
"base_url": "redacted-prgs-service",
|
||||||
|
"username": "reconciler-agent",
|
||||||
|
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||||
|
"execution_profile": "prgs-reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"read",
|
||||||
|
"pr.close",
|
||||||
|
"pr.comment",
|
||||||
|
"issue.comment",
|
||||||
|
"issue.close",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"merge",
|
||||||
|
"approve",
|
||||||
|
"review",
|
||||||
|
"pr.create",
|
||||||
|
"branch.push",
|
||||||
|
"commit",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||||
|
reconciler = (
|
||||||
|
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||||
|
["identities"]["reconciler"]
|
||||||
|
)
|
||||||
|
self.assertEqual(reconciler["role"], "reconciler")
|
||||||
|
allowed = reconciler["allowed_operations"]
|
||||||
|
forbidden = reconciler["forbidden_operations"]
|
||||||
|
# No invalid shorthand remains
|
||||||
|
for bad in ("pr.close", "pr.comment", "issue.close", "read", "merge"):
|
||||||
|
self.assertNotIn(bad, allowed)
|
||||||
|
self.assertNotIn(bad, forbidden)
|
||||||
|
for required in (
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
):
|
||||||
|
self.assertIn(required, allowed)
|
||||||
|
# Production loader accepts every allowed op
|
||||||
|
self.assertEqual(
|
||||||
|
gitea_config.normalize_operation(required), required
|
||||||
|
)
|
||||||
|
self.assertEqual(v2_data["aliases"]["prgs-reconciler"], "prgs.gitea.reconciler")
|
||||||
|
assessment = reconciler_profile.assess_reconciler_profile(allowed, forbidden)
|
||||||
|
self.assertTrue(assessment["valid"])
|
||||||
|
self.assertTrue(migrate_profiles.validate_v2_data(v2_data))
|
||||||
|
|
||||||
|
def test_reconciler_profile_defaults(self):
|
||||||
|
"""Reconciler defaults are fully canonical and loader-valid."""
|
||||||
|
import gitea_config
|
||||||
|
import reconciler_profile
|
||||||
|
|
||||||
|
v1_data = {
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {
|
||||||
|
"prgs-reconciler": {
|
||||||
|
"base_url": "redacted-prgs-service",
|
||||||
|
"username": "reconciler-agent",
|
||||||
|
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||||
|
"execution_profile": "prgs-reconciler",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v2_data = migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||||
|
reconciler = (
|
||||||
|
v2_data["environments"]["prgs"]["services"]["gitea"]
|
||||||
|
["identities"]["reconciler"]
|
||||||
|
)
|
||||||
|
self.assertEqual(reconciler["role"], "reconciler")
|
||||||
|
self.assertEqual(
|
||||||
|
reconciler["allowed_operations"],
|
||||||
|
migrate_profiles.RECONCILER_DEFAULT_ALLOWED,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
reconciler["forbidden_operations"],
|
||||||
|
migrate_profiles.RECONCILER_DEFAULT_FORBIDDEN,
|
||||||
|
)
|
||||||
|
for op in reconciler["allowed_operations"]:
|
||||||
|
self.assertEqual(gitea_config.normalize_operation(op), op)
|
||||||
|
self.assertTrue(op.startswith("gitea."))
|
||||||
|
assessment = reconciler_profile.assess_reconciler_profile(
|
||||||
|
reconciler["allowed_operations"],
|
||||||
|
reconciler["forbidden_operations"],
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment["valid"])
|
||||||
|
self.assertNotIn(
|
||||||
|
"gitea.branch.delete", assessment["missing_recommended_operations"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciler_migration_idempotent_canonicalize(self):
|
||||||
|
"""Second canonicalize of already-canonical ops is a no-op."""
|
||||||
|
first = migrate_profiles.canonicalize_operations(
|
||||||
|
list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED)
|
||||||
|
)
|
||||||
|
second = migrate_profiles.canonicalize_operations(first)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertEqual(first, list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED))
|
||||||
|
|
||||||
|
def test_reconciler_missing_required_fails_visibly(self):
|
||||||
|
"""Missing gitea.pr.close after migration fails closed (not silent drop)."""
|
||||||
|
v1_data = {
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {
|
||||||
|
"prgs-reconciler": {
|
||||||
|
"base_url": "redacted-prgs-service",
|
||||||
|
"username": "reconciler-agent",
|
||||||
|
"auth": {"type": "keychain", "id": "reconciler-ref"},
|
||||||
|
"execution_profile": "prgs-reconciler",
|
||||||
|
"allowed_operations": ["read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": ["merge"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(ValueError, "missing required"):
|
||||||
|
migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||||
|
|
||||||
|
def test_unknown_operation_fails_visibly(self):
|
||||||
|
v1_data = {
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"base_url": "redacted-prgs-service",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"auth": {"type": "keychain", "id": "hidden-author-ref"},
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
"allowed_operations": ["read", "not.a.real.op"],
|
||||||
|
"forbidden_operations": ["merge"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with self.assertRaisesRegex(ValueError, "cannot be canonicalized"):
|
||||||
|
migrate_profiles.migrate_v1_to_v2(v1_data)
|
||||||
|
|
||||||
|
def test_role_inference_author_reviewer_merger_reconciler(self):
|
||||||
|
self.assertEqual(
|
||||||
|
migrate_profiles.infer_role("prgs-author", "prgs-author"), "author"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
migrate_profiles.infer_role("prgs-reviewer", "prgs-reviewer"),
|
||||||
|
"reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
migrate_profiles.infer_role("prgs-merger", "prgs-merger")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""#727 / PR #728: author ownership when tracking issue number != PR number.
|
||||||
|
|
||||||
|
Regression for REQUEST_CHANGES: gitea_update_pr_branch_by_merge must not require
|
||||||
|
issue_number == pr_number. Ownership is proven via linked issue + lock/branch
|
||||||
|
context and fails closed for unrelated issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import issue_lock_store
|
||||||
|
import gitea_mcp_server as mcp
|
||||||
|
|
||||||
|
|
||||||
|
def _live_lock(
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str = "feat/issue-727-pr-sync-status",
|
||||||
|
worktree_path: str = "/tmp/branches/issue-727-pr-sync-status",
|
||||||
|
remote: str = "prgs",
|
||||||
|
org: str = "Scaled-Tech-Consulting",
|
||||||
|
repo: str = "Gitea-Tools",
|
||||||
|
) -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"acquired_at": now.isoformat(),
|
||||||
|
"expires_at": (now + timedelta(hours=2)).isoformat(),
|
||||||
|
"owner_pid": os.getpid(),
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory(prefix="gitea-issue-locks-")
|
||||||
|
self.lock_dir = self._tmp.name
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# Clear session pointer for this process.
|
||||||
|
try:
|
||||||
|
ptr = issue_lock_store.session_pointer_path(self.lock_dir)
|
||||||
|
if os.path.isfile(ptr):
|
||||||
|
os.unlink(ptr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
os.environ.pop("GITEA_ISSUE_LOCK_DIR", None)
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_issue_727_pr_728_session_lock_accepted(self):
|
||||||
|
"""Tracking issue #727 lock is valid ownership for PR #728."""
|
||||||
|
lock = _live_lock(issue_number=727)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727\n\nNative PR sync lifecycle.",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertTrue(result["has_author_lock"])
|
||||||
|
self.assertEqual(result["matched_issue"], 727)
|
||||||
|
self.assertEqual(result["matched_via"], "session_lock")
|
||||||
|
self.assertIn(727, result["linked_issues"])
|
||||||
|
|
||||||
|
def test_issue_727_pr_728_durable_lock_accepted(self):
|
||||||
|
lock = _live_lock(issue_number=727)
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
issue_number=727,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
issue_lock_store.save_lock_file(path, lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Fixes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertEqual(result["matched_issue"], 727)
|
||||||
|
self.assertEqual(result["matched_via"], "durable_lock")
|
||||||
|
|
||||||
|
def test_legacy_same_number_still_accepted(self):
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=100,
|
||||||
|
branch_name="fix/issue-100",
|
||||||
|
worktree_path="/tmp/branches/issue-100",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=100,
|
||||||
|
pr_title="fix something",
|
||||||
|
pr_body="Closes #100",
|
||||||
|
source_branch="fix/issue-100",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result)
|
||||||
|
self.assertEqual(result["matched_issue"], 100)
|
||||||
|
|
||||||
|
def test_unrelated_issue_lock_rejected(self):
|
||||||
|
"""Lock for issue #999 must not authorize PR #728 linked only to #727."""
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=999,
|
||||||
|
branch_name="feat/issue-999",
|
||||||
|
worktree_path="/tmp/branches/issue-999",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
path = issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
issue_number=999,
|
||||||
|
lock_dir=self.lock_dir,
|
||||||
|
)
|
||||||
|
issue_lock_store.save_lock_file(path, lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertFalse(result["has_author_lock"])
|
||||||
|
self.assertIsNone(result["matched_issue"])
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
|
||||||
|
def test_branch_mismatch_on_session_lock_fail_closed(self):
|
||||||
|
lock = _live_lock(
|
||||||
|
issue_number=727,
|
||||||
|
branch_name="feat/other-branch",
|
||||||
|
)
|
||||||
|
issue_lock_store.bind_session_lock(lock)
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
worktree_path=lock["worktree_path"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertTrue(any("branch" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_no_lock_fail_closed(self):
|
||||||
|
result = mcp._prove_author_ownership_for_pr(
|
||||||
|
pr_number=728,
|
||||||
|
pr_title="feat: pr sync",
|
||||||
|
pr_body="Closes #727",
|
||||||
|
source_branch="feat/issue-727-pr-sync-status",
|
||||||
|
remote="prgs",
|
||||||
|
host=None,
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"], result)
|
||||||
|
self.assertTrue(any("no live author issue lock" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""Hermetic tests for PR synchronization / conflict-remediation lifecycle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pr_sync_status import (
|
||||||
|
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
||||||
|
ACTION_BLOCKED,
|
||||||
|
ACTION_FRESH_REVIEW_REQUIRED,
|
||||||
|
ACTION_MERGE_NOW,
|
||||||
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
||||||
|
assess_post_update_head_transition,
|
||||||
|
assess_pr_sync_status,
|
||||||
|
assess_sequential_queue_step,
|
||||||
|
assess_update_pr_branch_preflight,
|
||||||
|
lease_usable_at_head,
|
||||||
|
merge_approval_usable_at_head,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(prefix: str) -> str:
|
||||||
|
return (prefix + "0" * 40)[:40]
|
||||||
|
|
||||||
|
|
||||||
|
PR_HEAD = _sha("aaaaaaaa")
|
||||||
|
BASE_HEAD = _sha("bbbbbbbb")
|
||||||
|
NEW_HEAD = _sha("cccccccc")
|
||||||
|
OLD_HEAD = _sha("dddddddd")
|
||||||
|
|
||||||
|
|
||||||
|
def _base_kwargs(**overrides):
|
||||||
|
data = {
|
||||||
|
"host": "gitea.prgs.cc",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"pr_number": 100,
|
||||||
|
"pr_state": "open",
|
||||||
|
"source_branch": "fix/issue-100",
|
||||||
|
"pr_head_sha": PR_HEAD,
|
||||||
|
"base_head_sha": BASE_HEAD,
|
||||||
|
"commits_behind": 0,
|
||||||
|
"mergeable": True,
|
||||||
|
"has_conflicts": False,
|
||||||
|
"branch_protection_requires_current_base": False,
|
||||||
|
"approval_at_current_head": True,
|
||||||
|
"checks_status": "success",
|
||||||
|
"active_author_lock": True,
|
||||||
|
"active_reviewer_lease": False,
|
||||||
|
"active_merger_lease": False,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssessPrSyncStatus(unittest.TestCase):
|
||||||
|
def test_approved_current_mergeable_merge_now(self):
|
||||||
|
result = assess_pr_sync_status(**_base_kwargs())
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertTrue(result["approval_valid_for_merge"])
|
||||||
|
self.assertFalse(result["stale_approval"])
|
||||||
|
self.assertEqual(result["pr_head_sha"], PR_HEAD)
|
||||||
|
self.assertEqual(result["base_head_sha"], BASE_HEAD)
|
||||||
|
|
||||||
|
def test_approved_outdated_update_not_required_merge_now(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
commits_behind=3,
|
||||||
|
branch_protection_requires_current_base=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW)
|
||||||
|
self.assertTrue(result["approval_valid_for_merge"])
|
||||||
|
self.assertTrue(any("does not require" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_outdated_update_required_no_conflict(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
commits_behind=2,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
mergeable=True,
|
||||||
|
has_conflicts=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
)
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
self.assertTrue(any("update_branch_by_merge" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_outdated_has_conflicts_author_remediation(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
commits_behind=5,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
mergeable=False,
|
||||||
|
has_conflicts=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_AUTHOR_CONFLICT_REMEDIATION
|
||||||
|
)
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_stale_approval_fresh_review_required(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
approval_at_current_head=False,
|
||||||
|
commits_behind=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
)
|
||||||
|
self.assertTrue(result["stale_approval"])
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_stale_prepared_verdict_cannot_cross_heads(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
approval_at_current_head=True,
|
||||||
|
prepared_verdict_head_sha=OLD_HEAD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
)
|
||||||
|
self.assertTrue(result["stale_prepared_verdict"])
|
||||||
|
self.assertFalse(result["approval_valid_for_merge"])
|
||||||
|
|
||||||
|
def test_missing_heads_fail_closed(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(pr_head_sha="short", base_head_sha=None)
|
||||||
|
)
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
self.assertTrue(any("PR head" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_closed_pr_blocked(self):
|
||||||
|
result = assess_pr_sync_status(**_base_kwargs(pr_state="closed"))
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_failed_checks_block_merge_now(self):
|
||||||
|
result = assess_pr_sync_status(**_base_kwargs(checks_status="failure"))
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
def test_stale_approval_with_update_required_routes_update(self):
|
||||||
|
result = assess_pr_sync_status(
|
||||||
|
**_base_kwargs(
|
||||||
|
approval_at_current_head=False,
|
||||||
|
commits_behind=1,
|
||||||
|
branch_protection_requires_current_base=True,
|
||||||
|
mergeable=True,
|
||||||
|
has_conflicts=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatePrBranchPreflight(unittest.TestCase):
|
||||||
|
def _ok_kwargs(self, **overrides):
|
||||||
|
data = {
|
||||||
|
"role_kind": "author",
|
||||||
|
"expected_pr_head_sha": PR_HEAD,
|
||||||
|
"live_pr_head_sha": PR_HEAD,
|
||||||
|
"expected_base_head_sha": BASE_HEAD,
|
||||||
|
"live_base_head_sha": BASE_HEAD,
|
||||||
|
"has_conflicts": False,
|
||||||
|
"mergeable": True,
|
||||||
|
"style": "merge",
|
||||||
|
"has_author_lock": True,
|
||||||
|
"worktree_path": "/Users/x/Development/Gitea-Tools/branches/issue-100",
|
||||||
|
"worktree_owns_source_branch": True,
|
||||||
|
"control_checkout_clean": True,
|
||||||
|
"master_parity_ok": True,
|
||||||
|
"force_push": False,
|
||||||
|
"rebase": False,
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def test_author_allowed_when_preflight_clean(self):
|
||||||
|
result = assess_update_pr_branch_preflight(**self._ok_kwargs())
|
||||||
|
self.assertTrue(result["mutation_allowed"])
|
||||||
|
self.assertFalse(result["head_race"])
|
||||||
|
self.assertFalse(result["base_race"])
|
||||||
|
|
||||||
|
def test_head_race_fail_closed(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(live_pr_head_sha=NEW_HEAD)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(result["head_race"])
|
||||||
|
self.assertTrue(any("PR head race" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_base_race_fail_closed(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(live_base_head_sha=NEW_HEAD)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(result["base_race"])
|
||||||
|
self.assertTrue(any("base head race" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_conflicts_structured_handoff_no_mutation(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(has_conflicts=True, mergeable=False)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(result["author_remediation_handoff"])
|
||||||
|
self.assertTrue(any("conflicts" in r.lower() for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_reviewer_denied(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(role_kind="reviewer")
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("author-only" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_merger_denied(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(role_kind="merger")
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("author-only" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_no_force_push(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(force_push=True)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("force-push" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_no_rebase(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(style="rebase", rebase=True)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("rebase" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_missing_lock_fail_closed(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(has_author_lock=False)
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
|
||||||
|
def test_worktree_not_under_branches(self):
|
||||||
|
result = assess_update_pr_branch_preflight(
|
||||||
|
**self._ok_kwargs(worktree_path="/tmp/not-a-branches-path")
|
||||||
|
)
|
||||||
|
self.assertFalse(result["mutation_allowed"])
|
||||||
|
self.assertTrue(any("branches/" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostUpdateHeadTransition(unittest.TestCase):
|
||||||
|
def test_update_invalidates_approval_and_requires_fresh_review(self):
|
||||||
|
result = assess_post_update_head_transition(
|
||||||
|
former_pr_head_sha=PR_HEAD,
|
||||||
|
new_pr_head_sha=NEW_HEAD,
|
||||||
|
former_approval_head_sha=PR_HEAD,
|
||||||
|
former_reviewer_lease_head_sha=PR_HEAD,
|
||||||
|
former_merger_lease_head_sha=PR_HEAD,
|
||||||
|
prepared_verdict_head_sha=PR_HEAD,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["head_changed"])
|
||||||
|
self.assertTrue(result["approval_invalidated"])
|
||||||
|
self.assertTrue(result["reviewer_lease_superseded"])
|
||||||
|
self.assertTrue(result["merger_lease_superseded"])
|
||||||
|
self.assertTrue(result["prepared_verdict_invalidated"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_head_unexpected(self):
|
||||||
|
result = assess_post_update_head_transition(
|
||||||
|
former_pr_head_sha=PR_HEAD,
|
||||||
|
new_pr_head_sha=PR_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["head_changed"])
|
||||||
|
self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleArtifacts(unittest.TestCase):
|
||||||
|
def test_stale_approval_cannot_authorize_merge(self):
|
||||||
|
result = merge_approval_usable_at_head(
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
approved_head_sha=PR_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["approval_usable"])
|
||||||
|
|
||||||
|
def test_fresh_approval_usable(self):
|
||||||
|
result = merge_approval_usable_at_head(
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
approved_head_sha=NEW_HEAD,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["approval_usable"])
|
||||||
|
|
||||||
|
def test_stale_reviewer_lease_cannot_cross_heads(self):
|
||||||
|
result = lease_usable_at_head(
|
||||||
|
lease_kind="reviewer",
|
||||||
|
lease_head_sha=PR_HEAD,
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["lease_usable"])
|
||||||
|
|
||||||
|
def test_stale_merger_lease_cannot_cross_heads(self):
|
||||||
|
result = lease_usable_at_head(
|
||||||
|
lease_kind="merger",
|
||||||
|
lease_head_sha=PR_HEAD,
|
||||||
|
current_head_sha=NEW_HEAD,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["lease_usable"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSequentialQueue(unittest.TestCase):
|
||||||
|
def test_reassess_after_merge_and_master_refresh(self):
|
||||||
|
result = assess_sequential_queue_step(
|
||||||
|
just_merged=True,
|
||||||
|
master_refreshed=True,
|
||||||
|
next_pr_number=101,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["reassess_allowed"])
|
||||||
|
self.assertTrue(result["process_next"])
|
||||||
|
self.assertTrue(result["post_merge_cleanup_handoff"])
|
||||||
|
self.assertEqual(result["next_pr_number"], 101)
|
||||||
|
|
||||||
|
def test_block_reassess_without_master_refresh(self):
|
||||||
|
result = assess_sequential_queue_step(
|
||||||
|
just_merged=True,
|
||||||
|
master_refreshed=False,
|
||||||
|
next_pr_number=101,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["reassess_allowed"])
|
||||||
|
self.assertTrue(any("master must be refreshed" in r for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -82,6 +82,42 @@ class TestReconcilerProfileModel(unittest.TestCase):
|
|||||||
"reconciler",
|
"reconciler",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_branch_delete_is_recommended_for_reconciler(self):
|
||||||
|
self.assertIn(
|
||||||
|
"gitea.branch.delete",
|
||||||
|
reconciler_profile.RECONCILER_RECOMMENDED_OPERATIONS,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"gitea.branch.delete",
|
||||||
|
reconciler_profile.RECONCILER_REQUIRED_OPERATIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciler_with_branch_delete_stays_valid(self):
|
||||||
|
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.branch.delete"]
|
||||||
|
result = reconciler_profile.assess_reconciler_profile(
|
||||||
|
allowed,
|
||||||
|
PRGS_RECONCILER_FORBIDDEN,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["is_reconciler_profile"])
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
self.assertNotIn(
|
||||||
|
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mcp_server._role_kind(allowed, PRGS_RECONCILER_FORBIDDEN),
|
||||||
|
"reconciler",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciler_without_branch_delete_reports_missing_recommended(self):
|
||||||
|
result = reconciler_profile.assess_reconciler_profile(
|
||||||
|
PRGS_RECONCILER_ALLOWED,
|
||||||
|
PRGS_RECONCILER_FORBIDDEN,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
self.assertIn(
|
||||||
|
"gitea.branch.delete", result["missing_recommended_operations"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -122,9 +122,11 @@ class TestApiRequestRetry(unittest.TestCase):
|
|||||||
sleep.assert_not_called()
|
sleep.assert_not_called()
|
||||||
|
|
||||||
def test_non_429_error_raises_immediately(self):
|
def test_non_429_error_raises_immediately(self):
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
_call([_http_error(500, body=b"boom")])
|
_call([_http_error(500, body=b"boom")])
|
||||||
self.assertIn("HTTP 500", str(ctx.exception))
|
# Fixed message only (#699) — no response body echo.
|
||||||
|
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||||
|
self.assertEqual(ctx.exception.http_status, 500)
|
||||||
|
|
||||||
def test_non_429_error_does_not_sleep(self):
|
def test_non_429_error_does_not_sleep(self):
|
||||||
sleep = MagicMock()
|
sleep = MagicMock()
|
||||||
@@ -171,9 +173,12 @@ class TestApiRequestRetry(unittest.TestCase):
|
|||||||
# max_retries=3 -> 3 sleeps, then the 4th failure raises.
|
# max_retries=3 -> 3 sleeps, then the 4th failure raises.
|
||||||
errors = [_http_error(429, retry_after="1") for _ in range(4)]
|
errors = [_http_error(429, retry_after="1") for _ in range(4)]
|
||||||
sleep = MagicMock()
|
sleep = MagicMock()
|
||||||
with self.assertRaises(RuntimeError) as ctx:
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
_call(errors, sleep_func=sleep, max_retries=3)
|
_call(errors, sleep_func=sleep, max_retries=3)
|
||||||
self.assertIn("HTTP 429", str(ctx.exception))
|
# After retries are exhausted, 429 is a typed HTTP error with fixed
|
||||||
|
# message (#699); status metadata carries 429.
|
||||||
|
self.assertEqual(str(ctx.exception), "Gitea HTTP request failed")
|
||||||
|
self.assertEqual(ctx.exception.http_status, 429)
|
||||||
self.assertEqual(sleep.call_count, 3)
|
self.assertEqual(sleep.call_count, 3)
|
||||||
|
|
||||||
def test_no_infinite_loop_when_always_429(self):
|
def test_no_infinite_loop_when_always_429(self):
|
||||||
|
|||||||
+101
-2
@@ -68,8 +68,6 @@ class TestReviewDrafts(unittest.TestCase):
|
|||||||
|
|
||||||
@patch("gitea_mcp_server.api_request")
|
@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 [])))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""Tests for optional self-hosted Sentry observability (#606).
|
||||||
|
|
||||||
|
Covers the pure module (config, redaction, event/check-in builders) and the
|
||||||
|
runtime capture paths using a fake ``sentry_sdk``, so nothing ever touches the
|
||||||
|
network. Critically proves the feature is a no-op when disabled or DSN-less
|
||||||
|
(acceptance criterion 1) and that secrets/paths/session-state are never sent
|
||||||
|
(criterion 5).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import contextlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import sentry_observability as so # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake SDK ────────────────────────────────────────────────────────────────
|
||||||
|
class _FakeScope:
|
||||||
|
def __init__(self):
|
||||||
|
self.tags = {}
|
||||||
|
self.extras = {}
|
||||||
|
|
||||||
|
def set_tag(self, k, v):
|
||||||
|
self.tags[k] = v
|
||||||
|
|
||||||
|
def set_extra(self, k, v):
|
||||||
|
self.extras[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSentrySDK:
|
||||||
|
"""Minimal stand-in exposing the SDK surface sentry_observability uses."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.init_kwargs = None
|
||||||
|
self.events = []
|
||||||
|
self.exceptions = []
|
||||||
|
self.checkins = []
|
||||||
|
self.last_scope = None
|
||||||
|
|
||||||
|
def init(self, **kwargs):
|
||||||
|
self.init_kwargs = kwargs
|
||||||
|
|
||||||
|
def capture_event(self, event):
|
||||||
|
self.events.append(event)
|
||||||
|
|
||||||
|
def capture_exception(self, exc):
|
||||||
|
self.exceptions.append(exc)
|
||||||
|
|
||||||
|
def capture_checkin(self, **payload):
|
||||||
|
self.checkins.append(payload)
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def push_scope(self):
|
||||||
|
self.last_scope = _FakeScope()
|
||||||
|
yield self.last_scope
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state():
|
||||||
|
"""Isolate module init state between tests."""
|
||||||
|
so.reset_for_tests()
|
||||||
|
yield
|
||||||
|
so.reset_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_sdk(monkeypatch):
|
||||||
|
sdk = FakeSentrySDK()
|
||||||
|
monkeypatch.setattr(so, "sentry_sdk", sdk)
|
||||||
|
return sdk
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config / gating ─────────────────────────────────────────────────────────
|
||||||
|
def test_disabled_by_default_empty_env():
|
||||||
|
cfg = so.load_config(env={})
|
||||||
|
assert cfg.enabled is False
|
||||||
|
assert cfg.active is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_enabled_flag_without_dsn_is_not_active():
|
||||||
|
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": "1"})
|
||||||
|
assert cfg.enabled is True
|
||||||
|
assert cfg.dsn is None
|
||||||
|
assert cfg.active is False # DSN required
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsn_without_enabled_flag_is_not_active():
|
||||||
|
cfg = so.load_config(env={"SENTRY_DSN": "https://[email protected]/1"})
|
||||||
|
assert cfg.active is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_requires_enabled_and_dsn():
|
||||||
|
cfg = so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "true", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
assert cfg.active is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_truthy_variants():
|
||||||
|
for val in ("1", "true", "YES", "On"):
|
||||||
|
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
||||||
|
assert cfg.enabled is True
|
||||||
|
for val in ("0", "false", "no", "", "off"):
|
||||||
|
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
|
||||||
|
assert cfg.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_traces_sample_rate_parsed_and_clamped():
|
||||||
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.25"}).traces_sample_rate == 0.25
|
||||||
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "5"}).traces_sample_rate == 1.0
|
||||||
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "-1"}).traces_sample_rate == 0.0
|
||||||
|
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "junk"}).traces_sample_rate == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_summary_has_no_dsn_value():
|
||||||
|
cfg = so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
summary = cfg.safe_summary()
|
||||||
|
assert summary["dsn_present"] is True
|
||||||
|
assert "secret" not in repr(summary)
|
||||||
|
assert "dsn" not in summary # only presence, never the value
|
||||||
|
|
||||||
|
|
||||||
|
# ── init_sentry ─────────────────────────────────────────────────────────────
|
||||||
|
def test_init_noop_when_disabled(fake_sdk):
|
||||||
|
status = so.init_sentry(so.load_config(env={}))
|
||||||
|
assert status["initialized"] is False
|
||||||
|
assert fake_sdk.init_kwargs is None # no SDK init
|
||||||
|
assert so.is_initialized() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_noop_when_enabled_but_missing_dsn(fake_sdk):
|
||||||
|
status = so.init_sentry(so.load_config(env={"MCP_SENTRY_ENABLED": "1"}))
|
||||||
|
assert status["initialized"] is False
|
||||||
|
assert fake_sdk.init_kwargs is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_reports_missing_sdk(monkeypatch):
|
||||||
|
monkeypatch.setattr(so, "sentry_sdk", None)
|
||||||
|
status = so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert status["initialized"] is False
|
||||||
|
assert "not installed" in status["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_configures_sdk_with_scrubber(fake_sdk):
|
||||||
|
status = so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={
|
||||||
|
"MCP_SENTRY_ENABLED": "1",
|
||||||
|
"SENTRY_DSN": "https://[email protected]/1",
|
||||||
|
"SENTRY_ENVIRONMENT": "prod",
|
||||||
|
"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert status["initialized"] is True
|
||||||
|
assert so.is_initialized() is True
|
||||||
|
kw = fake_sdk.init_kwargs
|
||||||
|
assert kw["dsn"] == "https://[email protected]/1"
|
||||||
|
assert kw["environment"] == "prod"
|
||||||
|
assert kw["traces_sample_rate"] == 0.1
|
||||||
|
assert kw["before_send"] is so.scrub_event
|
||||||
|
assert kw["send_default_pii"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_enable_logs_wires_log_scrubber(fake_sdk):
|
||||||
|
so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={
|
||||||
|
"MCP_SENTRY_ENABLED": "1",
|
||||||
|
"SENTRY_DSN": "https://[email protected]/1",
|
||||||
|
"MCP_SENTRY_ENABLE_LOGS": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
exp = fake_sdk.init_kwargs["_experiments"]
|
||||||
|
assert exp["enable_logs"] is True
|
||||||
|
assert exp["before_send_log"] is so.scrub_event
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_never_raises_on_sdk_failure(monkeypatch):
|
||||||
|
class Boom:
|
||||||
|
def init(self, **kwargs):
|
||||||
|
raise RuntimeError("sentry down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(so, "sentry_sdk", Boom())
|
||||||
|
status = so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert status["initialized"] is False
|
||||||
|
assert "init failed" in status["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Redaction (fail closed) ─────────────────────────────────────────────────
|
||||||
|
def test_build_tags_allowlist_only():
|
||||||
|
tags = so.build_tags(role="author", secret_thing="leak", pid=123)
|
||||||
|
assert tags["role"] == "author"
|
||||||
|
assert tags["pid"] == "123"
|
||||||
|
assert "secret_thing" not in tags
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tags_hashes_session_id():
|
||||||
|
tags = so.build_tags(session_id="prgs-author-20479-cf9ac178")
|
||||||
|
assert "session_id" not in tags
|
||||||
|
assert "session_id_hash" in tags
|
||||||
|
assert tags["session_id_hash"] != "prgs-author-20479-cf9ac178"
|
||||||
|
assert len(tags["session_id_hash"]) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tags_collapses_worktree_path():
|
||||||
|
tags = so.build_tags(
|
||||||
|
worktree_path="/Users/x/Development/Gitea-Tools/branches/issue-606-sentry-observability"
|
||||||
|
)
|
||||||
|
assert "worktree_path" not in tags
|
||||||
|
assert tags["worktree_category"] == "author"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tags_drops_value_that_scrubs_to_redacted():
|
||||||
|
# A tag value that is itself a token gets scrubbed then dropped.
|
||||||
|
tags = so.build_tags(capability="token abcdef1234567890")
|
||||||
|
assert "capability" not in tags
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_path_categories():
|
||||||
|
assert so.sanitize_path("/repo/branches/review-pr-654") == "reviewer"
|
||||||
|
assert so.sanitize_path("/repo/branches/merge-pr-1") == "merger"
|
||||||
|
assert so.sanitize_path("/repo/branches/reconcile-pr-1") == "reconciler"
|
||||||
|
assert so.sanitize_path("/repo/branches/feat-issue-606") == "author"
|
||||||
|
assert so.sanitize_path("/x/y/Gitea-Tools") == "root"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_value_scrubs_secrets_and_paths():
|
||||||
|
out = so.redact_value(
|
||||||
|
{
|
||||||
|
"token": "abc123",
|
||||||
|
"note": "Authorization: Bearer sk_live_abcdefgh12345",
|
||||||
|
"path": "/Users/jasonwalker/Development/Gitea-Tools/secret",
|
||||||
|
"dsn": "https://[email protected]/1",
|
||||||
|
"safe": "hello",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert out["token"] == so.REDACTED
|
||||||
|
assert out["dsn"] == so.REDACTED
|
||||||
|
assert "sk_live" not in out["note"]
|
||||||
|
assert so.REDACTED_PATH in out["path"]
|
||||||
|
assert "/Users/" not in out["path"]
|
||||||
|
assert out["safe"] == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_value_forbidden_prompt_and_session_state():
|
||||||
|
out = so.redact_value(
|
||||||
|
{"prompt": "full body", "session_state": "{...}", "keep": "ok"}
|
||||||
|
)
|
||||||
|
assert out["prompt"] == so.REDACTED
|
||||||
|
assert out["session_state"] == so.REDACTED
|
||||||
|
assert out["keep"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_event_redacts_nested_and_drops_server_name():
|
||||||
|
event = {
|
||||||
|
"server_name": "some-host",
|
||||||
|
"message": "boom",
|
||||||
|
"extra": {"token": "leak", "ok": "1"},
|
||||||
|
}
|
||||||
|
scrubbed = so.scrub_event(event)
|
||||||
|
assert "server_name" not in scrubbed
|
||||||
|
assert scrubbed["extra"]["token"] == so.REDACTED
|
||||||
|
assert scrubbed["extra"]["ok"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_event_drops_non_dict():
|
||||||
|
assert so.scrub_event("not a dict") is None
|
||||||
|
assert so.scrub_event(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event builders ──────────────────────────────────────────────────────────
|
||||||
|
def test_build_blocker_event_structure_and_next_action():
|
||||||
|
event = so.build_blocker_event(
|
||||||
|
"active_foreign_lease",
|
||||||
|
message="blocked by foreign lease",
|
||||||
|
next_action="wait or adopt via allocator",
|
||||||
|
level="warning",
|
||||||
|
tags={"pr_number": 606, "session_id": "s-123"},
|
||||||
|
)
|
||||||
|
assert event["tags"]["blocker_type"] == "active_foreign_lease"
|
||||||
|
assert event["tags"]["pr_number"] == "606"
|
||||||
|
assert "session_id" not in event["tags"]
|
||||||
|
assert event["tags"]["session_id_hash"]
|
||||||
|
assert event["extra"]["canonical_next_action"] == "wait or adopt via allocator"
|
||||||
|
assert event["fingerprint"] == ["workflow-blocker", "active_foreign_lease"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_blocker_event_invalid_level_defaults_warning():
|
||||||
|
event = so.build_blocker_event("x", level="nonsense")
|
||||||
|
assert event["level"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checkin_payload_maps_slugs():
|
||||||
|
for key, slug in so.MONITOR_SLUGS.items():
|
||||||
|
payload = so.build_checkin_payload(key, "ok")
|
||||||
|
assert payload["monitor_slug"] == slug
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checkin_payload_explicit_slug_passthrough():
|
||||||
|
payload = so.build_checkin_payload("custom-slug", "in_progress", duration=1.5)
|
||||||
|
assert payload["monitor_slug"] == "custom-slug"
|
||||||
|
assert payload["duration"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checkin_payload_rejects_bad_status():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
so.build_checkin_payload("allocator_health", "bogus")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_six_monitors_registered():
|
||||||
|
assert set(so.MONITOR_SLUGS) == {
|
||||||
|
"stale_lease_scan",
|
||||||
|
"terminal_lock_scan",
|
||||||
|
"allocator_health",
|
||||||
|
"namespace_health",
|
||||||
|
"dashboard_freshness",
|
||||||
|
"reconciler_cleanup",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Capture paths (fail open) ───────────────────────────────────────────────
|
||||||
|
def test_capture_workflow_blocker_noop_when_disabled(fake_sdk):
|
||||||
|
# not initialised
|
||||||
|
event = so.capture_workflow_blocker("some_blocker", message="x")
|
||||||
|
assert isinstance(event, dict) # still returns redacted event
|
||||||
|
assert fake_sdk.events == [] # but nothing sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_workflow_blocker_sends_when_initialised(fake_sdk):
|
||||||
|
so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
so.capture_workflow_blocker("terminal_lock_occupied", message="held")
|
||||||
|
assert len(fake_sdk.events) == 1
|
||||||
|
assert fake_sdk.events[0]["tags"]["blocker_type"] == "terminal_lock_occupied"
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_exception_noop_when_disabled(fake_sdk):
|
||||||
|
assert so.capture_exception(ValueError("x")) is False
|
||||||
|
assert fake_sdk.exceptions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_exception_sends_scrubbed_tags(fake_sdk):
|
||||||
|
so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ok = so.capture_exception(
|
||||||
|
RuntimeError("bad"), tags={"mutation_tool": "gitea_merge_pr", "leaky": "x"}
|
||||||
|
)
|
||||||
|
assert ok is True
|
||||||
|
assert len(fake_sdk.exceptions) == 1
|
||||||
|
assert fake_sdk.last_scope.tags["mutation_tool"] == "gitea_merge_pr"
|
||||||
|
assert "leaky" not in fake_sdk.last_scope.tags
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_exception_never_raises(monkeypatch, fake_sdk):
|
||||||
|
so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def boom(exc):
|
||||||
|
raise RuntimeError("sdk exploded")
|
||||||
|
|
||||||
|
monkeypatch.setattr(fake_sdk, "capture_exception", boom)
|
||||||
|
# Must swallow the SDK failure (fail open).
|
||||||
|
assert so.capture_exception(ValueError("y")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_checkin_noop_when_disabled(fake_sdk):
|
||||||
|
payload = so.monitor_checkin("allocator_health", "ok")
|
||||||
|
assert payload["monitor_slug"] == "gitea-mcp-allocator-health"
|
||||||
|
assert fake_sdk.checkins == [] # not sent while disabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_checkin_sends_when_initialised(fake_sdk):
|
||||||
|
so.init_sentry(
|
||||||
|
so.load_config(
|
||||||
|
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
so.monitor_checkin("stale_lease_scan", "ok")
|
||||||
|
assert fake_sdk.checkins == [
|
||||||
|
{"monitor_slug": "gitea-mcp-stale-lease-scan", "status": "ok"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_checkin_invalid_status_returns_none(fake_sdk):
|
||||||
|
assert so.monitor_checkin("allocator_health", "bogus") is None
|
||||||
|
assert fake_sdk.checkins == []
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616).
|
||||||
|
|
||||||
|
Enforces review 443 remediation:
|
||||||
|
|
||||||
|
* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2).
|
||||||
|
* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process
|
||||||
|
restart is operator-owned; ADR is the authoritative split.
|
||||||
|
* F3 — routine post-merge master-parity staleness has a sanctioned response
|
||||||
|
(stop → report → operator reload → re-verify parity) and is not a full
|
||||||
|
promotion ledger requirement.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ADR = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "architecture"
|
||||||
|
/ "mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
)
|
||||||
|
ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md"
|
||||||
|
|
||||||
|
CROSS_LINK_DOCS = (
|
||||||
|
REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md",
|
||||||
|
REPO_ROOT / "docs" / "wiki" / "Runbooks.md",
|
||||||
|
REPO_ROOT / "docs" / "llm-workflow-runbooks.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> str:
|
||||||
|
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_exists_with_policy_core():
|
||||||
|
text = _read(ADR)
|
||||||
|
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||||
|
assert "#615" in text
|
||||||
|
assert "stable control runtime" in text.lower()
|
||||||
|
assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_f1_operator_docs_cross_link_adr():
|
||||||
|
for path in CROSS_LINK_DOCS:
|
||||||
|
text = _read(path)
|
||||||
|
assert ADR_BASENAME in text, (
|
||||||
|
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
||||||
|
f"(issue #615 acceptance criterion 2)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling():
|
||||||
|
text = _read(ADR)
|
||||||
|
# Acceptance section must require guide/runbook cross-links.
|
||||||
|
assert "Operator guide / runbooks cross-link" in text or (
|
||||||
|
"operator guide" in text.lower() and "cross-link" in text.lower()
|
||||||
|
and "Acceptance" in text
|
||||||
|
)
|
||||||
|
# Cross-link must not remain only as optional tooling item #4.
|
||||||
|
optional = text.split("## 5. Implementation follow-ups", 1)[-1].split(
|
||||||
|
"## 6. Acceptance", 1
|
||||||
|
)[0]
|
||||||
|
assert "Operator Guide wiki cross-link to this ADR" not in optional, (
|
||||||
|
"ADR §5 must not list operator-guide cross-link as optional tooling"
|
||||||
|
)
|
||||||
|
assert "Not optional" in text or "must** cross-link" in text.lower() or (
|
||||||
|
"must cross-link" in text.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_runbook_fallback_is_operator_owned_not_llm_restart():
|
||||||
|
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||||
|
# Forbidden historical self-service instruction.
|
||||||
|
forbidden = (
|
||||||
|
"the LLM must relaunch or restart the client/MCP with the correct "
|
||||||
|
"profile environment variable before claiming or working on any tasks"
|
||||||
|
)
|
||||||
|
assert forbidden not in runbooks, (
|
||||||
|
"llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP"
|
||||||
|
)
|
||||||
|
assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks
|
||||||
|
assert ADR_BASENAME in runbooks
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_workspace_rebind_does_not_require_llm_process_relaunch():
|
||||||
|
runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md")
|
||||||
|
section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1]
|
||||||
|
section = section.split("## Safety notes", 1)[0]
|
||||||
|
# Must not tell the LLM alone to relaunch the MCP process as step 2.
|
||||||
|
assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section
|
||||||
|
assert "Operator-owned" in section or "operator" in section.lower()
|
||||||
|
assert "worktree_path" in section
|
||||||
|
collapsed = " ".join(section.lower().split())
|
||||||
|
assert "client reconnect" in collapsed
|
||||||
|
|
||||||
|
|
||||||
|
def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "must not" in lower and "restart" in lower
|
||||||
|
assert "operator" in lower and "reload" in lower
|
||||||
|
assert "supersedes" in lower
|
||||||
|
assert "llm" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_f3_adr_defines_post_merge_parity_staleness_response():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "2.6" in text
|
||||||
|
assert "post-merge" in lower or "post merge" in lower
|
||||||
|
assert "parity" in lower and "stale" in lower
|
||||||
|
# Sanctioned steps: stop, report, operator reload, re-verify.
|
||||||
|
assert "stop" in lower
|
||||||
|
assert "report" in lower
|
||||||
|
assert "operator" in lower
|
||||||
|
assert "parity is verified" in lower or "re-verif" in lower or (
|
||||||
|
"startup/current-head" in lower
|
||||||
|
)
|
||||||
|
# Not a full promotion ledger for routine reload.
|
||||||
|
assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or (
|
||||||
|
"not a §2.4" in text or "Not a §2.4 promotion" in text
|
||||||
|
)
|
||||||
|
# Stale parity listed among unhealthy triggers.
|
||||||
|
assert "master parity is stale" in lower or "stale master parity" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_f3_adr_forbids_llm_bypass_of_parity_gate():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "bypass" in lower or "self-reset" in lower or "self-service" in lower
|
||||||
|
assert "parity" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets():
|
||||||
|
for path in CROSS_LINK_DOCS:
|
||||||
|
text = _read(path)
|
||||||
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||||
|
assert marker not in text, f"{path} contains {marker!r}"
|
||||||
@@ -188,20 +188,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"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""Structured MCP auth errors and stdio transport survival (#699 / PR #701).
|
||||||
|
|
||||||
|
Covers original AC plus reviewer-ratified regressions:
|
||||||
|
|
||||||
|
1. Adversarial response-body, Keychain-content, and daemon-log secret checks
|
||||||
|
2. Real stdio authentication failure followed by a successful second call
|
||||||
|
3. UrlElicitationRequiredError framework re-raise behavior
|
||||||
|
4. Unexpected parser RuntimeError is not authentication
|
||||||
|
5. Generic HTTP 403 is authorization (not internal)
|
||||||
|
6. Repeated install/import is idempotent
|
||||||
|
7. Author and reconciler profiles
|
||||||
|
8. Native provenance non-bypass
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import gitea_auth
|
||||||
|
import mcp_tool_error_boundary as boundary
|
||||||
|
from tests.test_api_reliability import FAKE_AUTH, URL, FakeResp, http_error
|
||||||
|
|
||||||
|
ADVERSARIAL_BODY = (
|
||||||
|
'secret-token-value-ABC123 keychain-password=hunter2 '
|
||||||
|
'Authorization: token ghp_leaked_secret_xyz '
|
||||||
|
'{"message":"invalid username, password or token","token":"supersecretXYZ"}'
|
||||||
|
)
|
||||||
|
KEYCHAIN_BLOB = "keychain-item-password=sekrit-from-security-find-generic"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# api_request / HTTP classification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestHttpClassification(unittest.TestCase):
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_401_typed_auth_fixed_message_no_body(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(401, ADVERSARIAL_BODY)
|
||||||
|
with self.assertRaises(gitea_auth.GiteaAuthError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
exc = ctx.exception
|
||||||
|
self.assertEqual(exc.reason_code, "auth_invalid_token")
|
||||||
|
self.assertEqual(exc.error_class, "authentication")
|
||||||
|
self.assertEqual(exc.http_status, 401)
|
||||||
|
msg = str(exc)
|
||||||
|
self.assertNotIn("supersecretXYZ", msg)
|
||||||
|
self.assertNotIn("ghp_leaked", msg)
|
||||||
|
self.assertNotIn("hunter2", msg)
|
||||||
|
self.assertNotIn(ADVERSARIAL_BODY, msg)
|
||||||
|
self.assertEqual(
|
||||||
|
msg, "Gitea authentication failed: invalid or revoked credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_403_scope_is_authz_scope(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(
|
||||||
|
403,
|
||||||
|
'{"message":"token does not have at least one of required scope(s)"}',
|
||||||
|
)
|
||||||
|
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertEqual(ctx.exception.reason_code, "authz_insufficient_scope")
|
||||||
|
self.assertEqual(ctx.exception.error_class, "authorization")
|
||||||
|
self.assertNotIn("token does not have", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_generic_403_is_authz_not_internal(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(403, '{"message":"user has no permission"}')
|
||||||
|
with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertEqual(ctx.exception.reason_code, "authz_denied")
|
||||||
|
self.assertEqual(ctx.exception.error_class, "authorization")
|
||||||
|
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
|
||||||
|
self.assertNotIn("user has no permission", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_network_fixed_message(self, mock_open):
|
||||||
|
mock_open.side_effect = TimeoutError("timed out contacting secret.example")
|
||||||
|
with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertEqual(str(ctx.exception), "Network error contacting Gitea")
|
||||||
|
self.assertNotIn("secret.example", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_malformed_json_not_auth(self, mock_open):
|
||||||
|
mock_open.return_value = FakeResp("not-json{")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError)
|
||||||
|
self.assertIn("malformed JSON", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_classify_http_status_central(self):
|
||||||
|
cls, reason, status = gitea_auth.classify_http_status(401)
|
||||||
|
self.assertIs(cls, gitea_auth.GiteaAuthError)
|
||||||
|
self.assertEqual(reason, "auth_invalid_token")
|
||||||
|
self.assertEqual(status, 401)
|
||||||
|
|
||||||
|
cls, reason, status = gitea_auth.classify_http_status(403)
|
||||||
|
self.assertIs(cls, gitea_auth.GiteaAuthzError)
|
||||||
|
self.assertEqual(reason, "authz_denied")
|
||||||
|
|
||||||
|
cls, reason, status = gitea_auth.classify_http_status(
|
||||||
|
403, body_hint="missing scope write:issue"
|
||||||
|
)
|
||||||
|
self.assertEqual(reason, "authz_insufficient_scope")
|
||||||
|
|
||||||
|
cls, reason, status = gitea_auth.classify_http_status(502)
|
||||||
|
self.assertIs(cls, gitea_auth.GiteaHttpError)
|
||||||
|
self.assertEqual(reason, "upstream_unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Boundary classification — no heuristics, no secret leakage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestBoundaryClassification(unittest.TestCase):
|
||||||
|
def test_auth_payload_fixed_message(self):
|
||||||
|
exc = gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
|
||||||
|
# Even if someone mutates __str__ path, classification uses fixed text.
|
||||||
|
result = boundary.to_call_tool_result(
|
||||||
|
exc, tool_name="gitea_whoami", profile_name="prgs-author", log=False
|
||||||
|
)
|
||||||
|
self.assertTrue(result.isError)
|
||||||
|
p = result.structuredContent
|
||||||
|
self.assertEqual(p["reason_code"], "auth_invalid_token")
|
||||||
|
self.assertEqual(p["error_class"], "authentication")
|
||||||
|
self.assertEqual(p["message"], boundary.fixed_message("auth_invalid_token"))
|
||||||
|
self.assertNotIn("supersecret", json.dumps(p))
|
||||||
|
self.assertEqual(p["profile"], "prgs-author")
|
||||||
|
|
||||||
|
def test_adversarial_exception_text_not_in_payload_or_log(self):
|
||||||
|
"""Poisoned exception text must never appear in result or daemon log."""
|
||||||
|
|
||||||
|
class PoisonedAuth(gitea_auth.GiteaAuthError):
|
||||||
|
def __str__(self):
|
||||||
|
return ADVERSARIAL_BODY
|
||||||
|
|
||||||
|
exc = PoisonedAuth(reason_code="auth_invalid_token")
|
||||||
|
buf = io.StringIO()
|
||||||
|
result = boundary.to_call_tool_result(
|
||||||
|
exc, tool_name="gitea_whoami", log=True
|
||||||
|
)
|
||||||
|
# Force log with poisoned classification attempt
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
boundary.log_sanitized_daemon_reason(c, tool_name="gitea_whoami", stream=buf)
|
||||||
|
blob = json.dumps(result.structuredContent) + result.content[0].text + buf.getvalue()
|
||||||
|
for secret in (
|
||||||
|
"supersecretXYZ",
|
||||||
|
"ghp_leaked",
|
||||||
|
"hunter2",
|
||||||
|
"keychain-password",
|
||||||
|
ADVERSARIAL_BODY[:40],
|
||||||
|
):
|
||||||
|
self.assertNotIn(secret, blob)
|
||||||
|
self.assertIn("reason_code=auth_invalid_token", buf.getvalue())
|
||||||
|
self.assertNotIn("detail=", buf.getvalue())
|
||||||
|
|
||||||
|
def test_keychain_content_not_in_daemon_log(self):
|
||||||
|
buf = io.StringIO()
|
||||||
|
# Simulate a classification that a buggy path might try to put secrets into
|
||||||
|
poisoned = {
|
||||||
|
"reason_code": "auth_invalid_token",
|
||||||
|
"error_class": "authentication",
|
||||||
|
"http_status": 401,
|
||||||
|
"message": KEYCHAIN_BLOB,
|
||||||
|
}
|
||||||
|
boundary.log_sanitized_daemon_reason(
|
||||||
|
poisoned, tool_name="gitea_whoami", stream=buf
|
||||||
|
)
|
||||||
|
line = buf.getvalue()
|
||||||
|
self.assertNotIn("sekrit", line)
|
||||||
|
self.assertNotIn("keychain-item", line)
|
||||||
|
self.assertIn("reason_code=auth_invalid_token", line)
|
||||||
|
|
||||||
|
def test_unexpected_parser_runtimeerror_not_auth(self):
|
||||||
|
"""Reviewer finding #3: parser RuntimeError must not become authentication."""
|
||||||
|
exc = RuntimeError(
|
||||||
|
"HTTP 401: invalid username, password or token while parsing"
|
||||||
|
)
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
self.assertEqual(c["error_class"], "internal")
|
||||||
|
self.assertEqual(c["reason_code"], "internal_error")
|
||||||
|
self.assertNotEqual(c["error_class"], "authentication")
|
||||||
|
self.assertFalse(boundary.is_known_client_failure(exc))
|
||||||
|
|
||||||
|
def test_is_known_client_failure_only_typed(self):
|
||||||
|
self.assertTrue(
|
||||||
|
boundary.is_known_client_failure(gitea_auth.GiteaAuthError())
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
boundary.is_known_client_failure(gitea_auth.GiteaAuthzError())
|
||||||
|
)
|
||||||
|
self.assertFalse(boundary.is_known_client_failure(RuntimeError("x")))
|
||||||
|
self.assertFalse(boundary.is_known_client_failure(ValueError("y")))
|
||||||
|
|
||||||
|
def test_authz_distinct_from_auth(self):
|
||||||
|
c = boundary.classify_exception(
|
||||||
|
gitea_auth.GiteaAuthzError(reason_code="authz_denied")
|
||||||
|
)
|
||||||
|
self.assertEqual(c["error_class"], "authorization")
|
||||||
|
self.assertNotEqual(c["error_class"], "authentication")
|
||||||
|
|
||||||
|
def test_author_and_reconciler_profiles(self):
|
||||||
|
exc = gitea_auth.GiteaAuthError()
|
||||||
|
for profile in ("prgs-author", "prgs-reconciler"):
|
||||||
|
r = boundary.to_call_tool_result(
|
||||||
|
exc, tool_name="gitea_whoami", profile_name=profile, log=False
|
||||||
|
)
|
||||||
|
self.assertEqual(r.structuredContent["profile"], profile)
|
||||||
|
|
||||||
|
def test_sanitize_failure_fails_closed(self):
|
||||||
|
"""If build_structured_error_payload is poisoned, fixed internal path wins."""
|
||||||
|
bad = {
|
||||||
|
"reason_code": "auth_invalid_token",
|
||||||
|
"error_class": "authentication",
|
||||||
|
"message": ADVERSARIAL_BODY, # must be replaced with fixed constant
|
||||||
|
"http_status": 401,
|
||||||
|
}
|
||||||
|
payload = boundary.build_structured_error_payload(bad)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["message"], boundary.fixed_message("auth_invalid_token")
|
||||||
|
)
|
||||||
|
self.assertNotIn("supersecret", payload["message"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool.run boundary — framework semantics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestToolRunBoundary(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from mcp.server.fastmcp.tools.base import Tool
|
||||||
|
|
||||||
|
boundary.install_tool_run_boundary(Tool)
|
||||||
|
self.Tool = Tool
|
||||||
|
|
||||||
|
def _tool(self, fn, name="demo_tool"):
|
||||||
|
return self.Tool.from_function(fn, name=name)
|
||||||
|
|
||||||
|
def test_auth_returns_is_error(self):
|
||||||
|
def boom() -> dict:
|
||||||
|
raise gitea_auth.GiteaAuthError()
|
||||||
|
|
||||||
|
result = asyncio.run(self._tool(boom, "gitea_whoami").run({}, convert_result=True))
|
||||||
|
self.assertTrue(result.isError)
|
||||||
|
self.assertEqual(result.structuredContent["reason_code"], "auth_invalid_token")
|
||||||
|
|
||||||
|
def test_url_elicitation_re_raised(self):
|
||||||
|
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||||
|
from mcp.types import ElicitRequestURLParams
|
||||||
|
|
||||||
|
def boom() -> dict:
|
||||||
|
raise UrlElicitationRequiredError(
|
||||||
|
[
|
||||||
|
ElicitRequestURLParams(
|
||||||
|
mode="url",
|
||||||
|
elicitationId="e1",
|
||||||
|
url="https://example.invalid/elicit",
|
||||||
|
message="need auth",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
tool = self._tool(boom, "elicitation_tool")
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
return await tool.run({}, convert_result=True)
|
||||||
|
|
||||||
|
with self.assertRaises(UrlElicitationRequiredError):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
def test_transport_survives_second_call(self):
|
||||||
|
state = {"n": 0}
|
||||||
|
|
||||||
|
def flaky() -> dict:
|
||||||
|
state["n"] += 1
|
||||||
|
if state["n"] == 1:
|
||||||
|
raise gitea_auth.GiteaAuthError()
|
||||||
|
return {"ok": True, "call": state["n"]}
|
||||||
|
|
||||||
|
tool = self._tool(flaky, "gitea_whoami")
|
||||||
|
|
||||||
|
async def _both():
|
||||||
|
first = await tool.run({}, convert_result=True)
|
||||||
|
second = await tool.run({}, convert_result=True)
|
||||||
|
return first, second
|
||||||
|
|
||||||
|
first, second = asyncio.run(_both())
|
||||||
|
self.assertTrue(first.isError)
|
||||||
|
self.assertEqual(first.structuredContent["error_class"], "authentication")
|
||||||
|
self.assertFalse(getattr(second, "isError", False))
|
||||||
|
self.assertIsNotNone(second)
|
||||||
|
|
||||||
|
def test_os_exit_not_called(self):
|
||||||
|
def boom() -> dict:
|
||||||
|
raise gitea_auth.GiteaAuthError()
|
||||||
|
|
||||||
|
with patch("os._exit") as mock_exit:
|
||||||
|
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
|
||||||
|
mock_exit.assert_not_called()
|
||||||
|
self.assertTrue(result.isError)
|
||||||
|
|
||||||
|
def test_internal_not_labeled_auth(self):
|
||||||
|
def boom() -> dict:
|
||||||
|
raise KeyError("unexpected internal bug")
|
||||||
|
|
||||||
|
result = asyncio.run(self._tool(boom).run({}, convert_result=True))
|
||||||
|
self.assertTrue(result.isError)
|
||||||
|
self.assertEqual(result.structuredContent["error_class"], "internal")
|
||||||
|
self.assertEqual(result.structuredContent["reason_code"], "internal_error")
|
||||||
|
|
||||||
|
def test_idempotent_install(self):
|
||||||
|
from mcp.server.fastmcp.tools.base import Tool
|
||||||
|
|
||||||
|
first = boundary.install_tool_run_boundary(Tool)
|
||||||
|
second = boundary.install_tool_run_boundary(Tool)
|
||||||
|
# After setUp, boundary is installed; both calls should be no-ops (False)
|
||||||
|
# or first True only if somehow reset — either way second must be False.
|
||||||
|
self.assertFalse(second)
|
||||||
|
self.assertTrue(boundary.boundary_is_installed(Tool))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Real stdio subprocess: auth error then successful second call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestStdioTransportSurvival(unittest.TestCase):
|
||||||
|
def test_stdio_auth_error_then_second_call(self):
|
||||||
|
"""Minimal FastMCP stdio-like in-process loop with the boundary installed.
|
||||||
|
|
||||||
|
Uses Tool.run (same path as FastMCP tool execution) to prove:
|
||||||
|
1) auth failure → isError structured result
|
||||||
|
2) subsequent call still returns normally
|
||||||
|
without starting a full MCP daemon (unit-speed, no network).
|
||||||
|
"""
|
||||||
|
from mcp.server.fastmcp.tools.base import Tool
|
||||||
|
|
||||||
|
boundary.install_tool_run_boundary(Tool)
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def whoami() -> dict:
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
# Simulate revoked credential → typed auth failure
|
||||||
|
raise gitea_auth.GiteaAuthError(reason_code="auth_invalid_token")
|
||||||
|
return {"authenticated": True, "username": "demo"}
|
||||||
|
|
||||||
|
tool = Tool.from_function(whoami, name="gitea_whoami")
|
||||||
|
|
||||||
|
async def session():
|
||||||
|
r1 = await tool.run({}, convert_result=True)
|
||||||
|
r2 = await tool.run({}, convert_result=True)
|
||||||
|
return r1, r2
|
||||||
|
|
||||||
|
r1, r2 = asyncio.run(session())
|
||||||
|
self.assertTrue(r1.isError)
|
||||||
|
self.assertEqual(r1.structuredContent["reason_code"], "auth_invalid_token")
|
||||||
|
self.assertTrue(r1.structuredContent["transport_survives"])
|
||||||
|
# Second call survives and returns success content
|
||||||
|
self.assertFalse(getattr(r2, "isError", False))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provenance non-bypass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestNativeProvenanceNonBypass(unittest.TestCase):
|
||||||
|
def test_env_flags_cannot_disable_classification(self):
|
||||||
|
env_keys = (
|
||||||
|
"GITEA_OFFLINE",
|
||||||
|
"GITEA_SKIP_AUTH_BOUNDARY",
|
||||||
|
"GITEA_MCP_OFFLINE",
|
||||||
|
"GITEA_BYPASS_NATIVE_MCP",
|
||||||
|
)
|
||||||
|
saved = {k: os.environ.get(k) for k in env_keys}
|
||||||
|
try:
|
||||||
|
for k in env_keys:
|
||||||
|
os.environ[k] = "1"
|
||||||
|
exc = gitea_auth.GiteaAuthError()
|
||||||
|
c = boundary.classify_exception(exc)
|
||||||
|
self.assertEqual(c["error_class"], "authentication")
|
||||||
|
r = boundary.to_call_tool_result(exc, log=False)
|
||||||
|
self.assertTrue(r.isError)
|
||||||
|
self.assertEqual(r.structuredContent["reason_code"], "auth_invalid_token")
|
||||||
|
finally:
|
||||||
|
for k, v in saved.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
def test_no_bypass_surface(self):
|
||||||
|
for name in dir(boundary):
|
||||||
|
lower = name.lower()
|
||||||
|
self.assertFalse(
|
||||||
|
lower.startswith("bypass") or lower.startswith("skip_native"),
|
||||||
|
msg=f"unexpected bypass surface: {name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# api_reliability regressions still hold for non-401 paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestApiReliabilityCompat(unittest.TestCase):
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_502_upstream_typed(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(502, "bad gateway secret=xyz")
|
||||||
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertEqual(ctx.exception.reason_code, "upstream_unavailable")
|
||||||
|
self.assertNotIn("secret=xyz", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_auth_header_never_in_error(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(400, "bad request")
|
||||||
|
with self.assertRaises(gitea_auth.GiteaHttpError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Invariant tests pinning task_capability_map role assignments (#722/#723).
|
||||||
|
|
||||||
|
Added with the operator-authorized break-glass repair for incident #722:
|
||||||
|
commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every
|
||||||
|
configured merger profile forbids the review permissions, so no configured
|
||||||
|
profile could resolve any formal review task (``matching_configured_profile``
|
||||||
|
was empty repository-wide). These tests fail loudly if that class of
|
||||||
|
regression recurs:
|
||||||
|
|
||||||
|
- every role-exclusive formal-review task must be satisfiable by at least one
|
||||||
|
canonical role profile (permission AND role together);
|
||||||
|
- the capability map must agree with ``role_session_router`` task sets;
|
||||||
|
- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of
|
||||||
|
970e68b, preserved by the repair);
|
||||||
|
- merger profiles must not be able to resolve review_pr/approve_pr.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import gitea_config
|
||||||
|
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
||||||
|
from task_capability_map import required_permission, required_role
|
||||||
|
|
||||||
|
# Canonical role-profile permission shape. Mirrors the configured
|
||||||
|
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||||
|
# reviewers review/approve/request changes but never merge; mergers merge but
|
||||||
|
# never review/approve/request changes.
|
||||||
|
CANONICAL_ROLE_PROFILES = {
|
||||||
|
"author": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.create",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.issue.close",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reviewer": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"merger": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reconciler": {
|
||||||
|
"allowed": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.pr.close",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
"gitea.decision_lock.irrecoverable_recovery",
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.review",
|
||||||
|
"gitea.pr.request_changes",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
"gitea.repo.commit",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive
|
||||||
|
# handling for review work): permission alone is not enough — the profile's
|
||||||
|
# role kind must also match, so both dimensions are pinned here.
|
||||||
|
FORMAL_REVIEW_TASKS = (
|
||||||
|
"review_pr",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"blind_pr_queue_review",
|
||||||
|
"pr_queue_cleanup",
|
||||||
|
"pr-queue-cleanup",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_satisfies(role_name, task):
|
||||||
|
"""True when the canonical *role_name* profile can perform *task*."""
|
||||||
|
profile = CANONICAL_ROLE_PROFILES[role_name]
|
||||||
|
ok, _reason = gitea_config.check_operation(
|
||||||
|
required_permission(task), profile["allowed"], profile["forbidden"]
|
||||||
|
)
|
||||||
|
return ok and role_name == required_role(task)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormalReviewProfileCoverage(unittest.TestCase):
|
||||||
|
"""#722: some configured profile must be able to formally review."""
|
||||||
|
|
||||||
|
def test_every_formal_review_task_has_a_satisfying_role_profile(self):
|
||||||
|
for task in FORMAL_REVIEW_TASKS:
|
||||||
|
with self.subTest(task=task):
|
||||||
|
satisfying = [
|
||||||
|
role
|
||||||
|
for role in CANONICAL_ROLE_PROFILES
|
||||||
|
if _profile_satisfies(role, task)
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
satisfying,
|
||||||
|
f"no canonical role profile satisfies both permission "
|
||||||
|
f"{required_permission(task)!r} and role "
|
||||||
|
f"{required_role(task)!r} for task {task!r} — formal "
|
||||||
|
f"review would be impossible for every configured "
|
||||||
|
f"profile (incident #722)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_formal_review_tasks_are_reviewer_role(self):
|
||||||
|
for task in FORMAL_REVIEW_TASKS:
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertEqual(required_role(task), "reviewer")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapRouterAgreement(unittest.TestCase):
|
||||||
|
"""#723 AC2: the map and the role session router must not drift."""
|
||||||
|
|
||||||
|
def test_reviewer_tasks_map_to_reviewer_role(self):
|
||||||
|
for task in sorted(REVIEWER_TASKS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertEqual(
|
||||||
|
required_role(task),
|
||||||
|
"reviewer",
|
||||||
|
f"router classifies {task!r} as a reviewer task but the "
|
||||||
|
f"capability map assigns role {required_role(task)!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merger_tasks_map_to_merger_role(self):
|
||||||
|
for task in sorted(MERGER_TASKS):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
self.assertEqual(
|
||||||
|
required_role(task),
|
||||||
|
"merger",
|
||||||
|
f"router classifies {task!r} as a merger task but the "
|
||||||
|
f"capability map assigns role {required_role(task)!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergerBoundary(unittest.TestCase):
|
||||||
|
"""Preserve the legitimate hunk of 970e68b and the merger fence."""
|
||||||
|
|
||||||
|
def test_adopt_merger_pr_lease_requires_merger_role(self):
|
||||||
|
self.assertEqual(required_role("adopt_merger_pr_lease"), "merger")
|
||||||
|
self.assertEqual(
|
||||||
|
required_permission("adopt_merger_pr_lease"), "gitea.pr.comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_merge_pr_requires_merger_role(self):
|
||||||
|
self.assertEqual(required_role("merge_pr"), "merger")
|
||||||
|
self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge")
|
||||||
|
|
||||||
|
def test_merger_profile_cannot_resolve_formal_review_tasks(self):
|
||||||
|
merger = CANONICAL_ROLE_PROFILES["merger"]
|
||||||
|
for task in ("review_pr", "approve_pr", "request_changes_pr"):
|
||||||
|
with self.subTest(task=task):
|
||||||
|
ok, reason = gitea_config.check_operation(
|
||||||
|
required_permission(task),
|
||||||
|
merger["allowed"],
|
||||||
|
merger["forbidden"],
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
ok,
|
||||||
|
f"merger profile must not hold {task!r} permission "
|
||||||
|
f"(got reason {reason!r})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Tests for the session hard-stop after a terminal review mutation (#332).
|
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
|
||||||
|
|
||||||
Extends the single-terminal-decision machinery (#211): after a live
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user