Merge branch 'master' into feat/issue-543-mcp-namespace-health-check
This commit is contained in:
+236
-4
@@ -833,6 +833,7 @@ import pr_work_lease # noqa: E402
|
||||
import workflow_skill # noqa: E402
|
||||
import conflict_fix_classification # noqa: E402
|
||||
import native_mcp_preference # noqa: E402
|
||||
import branch_cleanup_guard # noqa: E402
|
||||
import thread_state_ledger_validator # noqa: E402
|
||||
import master_parity_gate # noqa: E402
|
||||
|
||||
@@ -4853,6 +4854,149 @@ def gitea_delete_branch(
|
||||
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_cleanup_merged_pr_branch(
|
||||
pr_number: int,
|
||||
confirmation: str,
|
||||
branch: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
|
||||
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
||||
if gate_reasons:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": gate_reasons,
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
|
||||
profile = get_profile()
|
||||
active_role = _role_kind(
|
||||
profile.get("allowed_operations", []),
|
||||
profile.get("forbidden_operations", []),
|
||||
)
|
||||
if active_role == "reviewer":
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
"reviewer profile is not authorized for merged branch cleanup "
|
||||
"(fail closed)"
|
||||
],
|
||||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||
}
|
||||
|
||||
if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"reasons": [
|
||||
"merged branch cleanup requires an explicit branches/ worktree "
|
||||
"path; root checkout branch ref mutation is blocked (fail closed)"
|
||||
],
|
||||
}
|
||||
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="cleanup_merged_pr_branch",
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
pr = api_request("GET", f"{base}/pulls/{pr_number}", auth)
|
||||
pr_head = pr.get("head") or {}
|
||||
head_branch = branch or pr_head.get("ref") or ""
|
||||
head_sha = pr_head.get("sha")
|
||||
target_branch = (pr.get("base") or {}).get("ref") or "master"
|
||||
|
||||
if branch and branch != pr_head.get("ref"):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"branch": branch,
|
||||
"reasons": [
|
||||
f"requested branch '{branch}' does not match PR head "
|
||||
f"'{pr_head.get('ref')}'"
|
||||
],
|
||||
}
|
||||
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
open_heads = {
|
||||
str((open_pr.get("head") or {}).get("ref"))
|
||||
for open_pr in open_prs
|
||||
if (open_pr.get("head") or {}).get("ref")
|
||||
}
|
||||
remote_exists = _remote_branch_exists(h, o, r, auth, head_branch)
|
||||
target_ref = (
|
||||
f"{remote}/{target_branch}"
|
||||
if remote in REMOTES
|
||||
else f"origin/{target_branch}"
|
||||
)
|
||||
head_on_target = merged_cleanup_reconcile.is_head_ancestor_of_ref(
|
||||
PROJECT_ROOT,
|
||||
head_sha,
|
||||
target_ref,
|
||||
)
|
||||
assessment = branch_cleanup_guard.assess_merged_pr_branch_cleanup(
|
||||
pr_number=pr_number,
|
||||
head_branch=head_branch,
|
||||
merged=bool(pr.get("merged") or pr.get("merged_at")),
|
||||
remote_branch_exists=remote_exists,
|
||||
open_pr_heads=open_heads,
|
||||
head_on_target=head_on_target,
|
||||
delete_capability_allowed=True,
|
||||
confirmation=confirmation,
|
||||
)
|
||||
if not assessment["safe_to_delete"]:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"assessment": assessment,
|
||||
"reasons": assessment["block_reasons"],
|
||||
}
|
||||
|
||||
import urllib.parse
|
||||
|
||||
encoded_branch = urllib.parse.quote(head_branch, safe="")
|
||||
url = f"{base}/branches/{encoded_branch}"
|
||||
with _audited(
|
||||
"cleanup_merged_pr_branch",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
target_branch=head_branch,
|
||||
request_metadata={
|
||||
"branch": head_branch,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"cleanup_path": "gitea_cleanup_merged_pr_branch",
|
||||
},
|
||||
):
|
||||
api_request("DELETE", url, auth)
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"pr_number": pr_number,
|
||||
"branch": head_branch,
|
||||
"message": f"Merged PR #{pr_number} source branch '{head_branch}' deleted.",
|
||||
"assessment": assessment,
|
||||
}
|
||||
|
||||
|
||||
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
|
||||
import urllib.parse
|
||||
|
||||
@@ -6010,7 +6154,9 @@ def _reviewer_pr_lease_gate(
|
||||
"""Return block reasons when the session lacks an owned PR reviewer lease."""
|
||||
session = reviewer_pr_lease.get_session_lease()
|
||||
session_id = (session or {}).get("session_id")
|
||||
identity = _authenticated_username(remote) or ""
|
||||
# Resolve host first: _authenticated_username expects a host, not remote name.
|
||||
h, _, _ = _resolve(remote, host, org, repo)
|
||||
identity = _authenticated_username(h) or ""
|
||||
try:
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
@@ -6064,7 +6210,8 @@ def gitea_acquire_reviewer_pr_lease(
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
profile = get_profile()
|
||||
identity = _authenticated_username(remote) or profile.get("username") or ""
|
||||
# Host (not remote name) — remote aliases like "prgs" do not resolve identity.
|
||||
identity = _authenticated_username(h) or profile.get("username") or ""
|
||||
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
|
||||
repo_label = f"{o}/{r}"
|
||||
|
||||
@@ -6207,7 +6354,8 @@ def gitea_adopt_merger_pr_lease(
|
||||
auth = _auth(h)
|
||||
profile = get_profile()
|
||||
profile_name = profile.get("profile_name") or "unknown"
|
||||
identity = _authenticated_username(remote) or profile.get("username") or ""
|
||||
# Host (not remote name) — remote aliases like "prgs" do not resolve identity.
|
||||
identity = _authenticated_username(h) or profile.get("username") or ""
|
||||
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
|
||||
repo_label = f"{o}/{r}"
|
||||
|
||||
@@ -6595,7 +6743,9 @@ def gitea_release_reviewer_pr_lease(
|
||||
"reasons": ["no active reviewer lease found on PR"],
|
||||
}
|
||||
|
||||
identity = _authenticated_username(remote) or ""
|
||||
# Host (not remote name). Passing "prgs"/"dadeschools" yields empty identity
|
||||
# and incorrectly blocks same-identity release across sessions.
|
||||
identity = _authenticated_username(h) or ""
|
||||
session = reviewer_pr_lease.get_session_lease() or {}
|
||||
session_id = session.get("session_id")
|
||||
|
||||
@@ -7912,6 +8062,44 @@ def gitea_get_shell_health() -> dict:
|
||||
return native_mcp_preference.shell_health_status()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_diagnose_terminal(
|
||||
cwd: str | None = None,
|
||||
command: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: Run active diagnostics on terminal launcher spawn ability (#556).
|
||||
|
||||
Args:
|
||||
cwd: Active worktree or current directory to test (defaults to GITEA_ACTIVE_WORKTREE).
|
||||
command: Test command to attempt (defaults to ['git', '--version'] or ['echo', 'ok']).
|
||||
"""
|
||||
resolved_cwd = cwd or os.environ.get("GITEA_ACTIVE_WORKTREE")
|
||||
if resolved_cwd:
|
||||
resolved_cwd = os.path.realpath(resolved_cwd)
|
||||
|
||||
probe_cmd = command or native_mcp_preference.default_terminal_probe_command()
|
||||
|
||||
diag = native_mcp_preference.diagnose_terminal_failure(resolved_cwd, probe_cmd)
|
||||
probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd, probe_cmd)
|
||||
|
||||
return {
|
||||
"healthy": probe_res["healthy"],
|
||||
"error_type": (
|
||||
None
|
||||
if probe_res["healthy"]
|
||||
else probe_res["error_type"] or diag["error_type"]
|
||||
),
|
||||
"error_msg": (
|
||||
""
|
||||
if probe_res["healthy"]
|
||||
else probe_res["error_msg"] or diag["error_msg"]
|
||||
),
|
||||
"cwd": resolved_cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_validate_review_final_report(
|
||||
report_text: str,
|
||||
@@ -9449,6 +9637,50 @@ def gitea_resolve_task_capability(
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
|
||||
# ── Terminal Launcher Preflight Check (#556) ──
|
||||
if task in native_mcp_preference.GITEA_MUTATION_TASKS:
|
||||
in_test = _preflight_in_test_mode()
|
||||
if not in_test or os.environ.get("GITEA_FORCE_TERMINAL_PROBE") == "1":
|
||||
resolved_cwd = os.environ.get("GITEA_ACTIVE_WORKTREE") or PROJECT_ROOT
|
||||
probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd)
|
||||
if not probe_res["healthy"]:
|
||||
# Only block if the failure is due to a missing CWD, missing shell, or the circuit breaker is tripped.
|
||||
# Other failures (like missing executable in the MCP process's limited PATH) should not block the agent's shell capability.
|
||||
is_real_blocker = (
|
||||
probe_res.get("error_type") in ("missing cwd", "missing shell")
|
||||
or native_mcp_preference.shell_health_status().get("hard_stopped")
|
||||
)
|
||||
if is_real_blocker:
|
||||
profile = get_profile()
|
||||
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
||||
username = _authenticated_username(h) if h else None
|
||||
next_safe_action = (
|
||||
"BLOCKED + DIAGNOSE: terminal-launcher-failure "
|
||||
f"({probe_res['error_type']}): {probe_res['error_msg']}. "
|
||||
"Do not attempt git/pytest finalization or unsafe fallbacks. "
|
||||
"Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop."
|
||||
)
|
||||
return {
|
||||
"requested_task": task,
|
||||
"required_operation_permission": required_permission,
|
||||
"required_role_kind": required_role,
|
||||
"active_profile": profile["profile_name"],
|
||||
"active_identity": username,
|
||||
"active_profile_allowed_operations": profile.get("allowed_operations") or [],
|
||||
"allowed_in_current_session": False,
|
||||
"stop_required": True,
|
||||
"infra_stop": True,
|
||||
"blocked": True,
|
||||
"blocked_reason": "BLOCKED + DIAGNOSE",
|
||||
"terminal_launcher_unhealthy": True,
|
||||
"terminal_launcher_diagnostics": probe_res,
|
||||
"task_role_guidance": [next_safe_action],
|
||||
"matching_configured_profile": [],
|
||||
"runtime_switching_supported": gitea_config.is_runtime_switching_enabled(),
|
||||
"different_mcp_namespace_required": False,
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
|
||||
record_preflight_check("capability", required_role, resolved_task=task)
|
||||
|
||||
# Try automatic dispatch switching
|
||||
|
||||
Reference in New Issue
Block a user