Files
Gitea-Tools/gitea_mcp_server.py
T
sysadmin af064dd06b fix: resolve conflicts for PR #378
Merge prgs/master and keep both review-merge state machine (#290) and
native MCP preference (#270) imports, skill entries, and MCP tools.
2026-07-07 09:57:01 -04:00

6152 lines
237 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Gitea MCP Server — exposes Gitea operations as MCP tools.
Runs over stdio. All tools authenticate via macOS keychain (git credential fill).
Usage (standalone test):
python3 mcp_server.py
Configuration (mcp_config.json):
"gitea-tools": {
"command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
"args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
"env": {}
}
"""
import json
import os
import re
import sys
import json
import functools
import contextlib
import subprocess
# Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a
# file: a /tmp lock is host-global, writable (spoofable) by any local process,
# goes silently stale across sessions, and races between concurrent agent
# sessions. This record lives and dies with the MCP server process, so it can
# never be forged from outside or leak between sessions. The CLI side-channel
# (a subprocess overriding GITEA_MCP_PROFILE to escalate roles) is covered by
# SESSION_PROFILE_LOCK_ENV below: the server exports its launch profile into
# the environment, children inherit it, and reviewer CLIs (review_pr.py)
# refuse to run under a different resolved profile.
_MUTATION_AUTHORITY: dict | None = None
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
def _export_session_profile_lock():
"""Export this process's launch profile for child CLI processes.
setdefault: an already-locked environment (outer session) wins, so a
nested launch cannot relabel the session.
"""
try:
name = (get_profile().get("profile_name") or "").strip()
if name:
os.environ.setdefault(SESSION_PROFILE_LOCK_ENV, name)
except Exception:
# Profile resolution problems surface loudly on the first real call;
# the lock export must not mask them here at import time.
pass
def record_mutation_authority(profile_name: str | None, identity: str | None,
remote: str | None, task: str | None):
"""Record the resolved capability context for this process (fail-closed
consumers in verify_mutation_authority)."""
global _MUTATION_AUTHORITY
_MUTATION_AUTHORITY = {
"initial_profile": profile_name,
"initial_identity": identity,
"current_profile": profile_name,
"current_identity": identity,
"remote": remote,
"task": task,
"role_pivot_authorized": False,
"role_pivot_record": None,
"pid": os.getpid(),
}
def verify_mutation_authority(remote: str | None, host: str | None = None,
required_role: str = "reviewer",
active_identity: str | None = None):
"""Verify the current mutation matches this process's recorded authority.
Fail-closed rules:
- No recorded authority (or one from another process after a fork) is
seeded from the live, config-resolved context — the approved preflight
path (whoami → eligibility → mutation) therefore works without an
explicit resolve call — but an unresolvable profile still fails closed.
- GITEA_SESSION_PROFILE_LOCK (set by the launching session) must match
the active profile: a mid-session GITEA_MCP_PROFILE override flips the
active profile away from the lock and is refused.
- Remote, profile, and identity must match the recorded authority.
- An author→reviewer pivot requires an authorized pivot record
(gitea_activate_profile in dynamic mode); it can never be improvised.
"""
global _MUTATION_AUTHORITY
profile = get_profile()
active_profile = profile.get("profile_name")
if active_identity is None:
# Callers that already proved the identity (eligibility gate) pass it
# in; otherwise resolve it here (cached, read-only).
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
active_identity = _authenticated_username(h) if h else None
if not active_profile:
raise RuntimeError(
"Mutation authority unavailable: active profile unresolved (fail closed)"
)
session_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
if session_lock and session_lock != active_profile and not gitea_config.is_runtime_switching_enabled():
raise RuntimeError(
f"Active profile '{active_profile}' does not match the session "
f"profile lock '{session_lock}' — profile side-channel override "
"rejected (fail closed)"
)
data = _MUTATION_AUTHORITY
if data is None or data.get("pid") != os.getpid():
# First mutation gate in this process (approved preflight path):
# seed the authority from the live context, then verify against it.
record_mutation_authority(
active_profile, active_identity, remote, "seeded-at-mutation-gate"
)
data = _MUTATION_AUTHORITY
if data.get("remote") != remote:
raise RuntimeError(
f"Mutation remote '{remote}' does not match locked remote "
f"'{data.get('remote')}' (fail closed)"
)
locked_profile = data.get("current_profile")
locked_identity = data.get("current_identity")
if active_profile != locked_profile or active_identity != locked_identity:
raise RuntimeError(
f"Mutation profile '{active_profile}' or identity '{active_identity}' "
f"does not match locked authority (profile: '{locked_profile}', "
f"identity: '{locked_identity}') (fail closed)"
)
# Reviewer/author role pivot boundary: only an authorized pivot
# (recorded by gitea_activate_profile) may cross author → reviewer.
if (required_role == "reviewer"
and "author" in str(data.get("initial_profile")).lower()
and "reviewer" in str(active_profile).lower()):
if not data.get("role_pivot_authorized"):
raise RuntimeError(
"Attempted reviewer mutation from author session without "
"authorized role pivot (fail closed)"
)
# Resolve the project root. MCP clients must launch this script directly with
# the venv interpreter (venv/bin/python3) — see the config example above. We do
# NOT os.execv() to re-point the interpreter: replacing the process after the
# client has already wired up the stdio pipes can desync the JSON-RPC transport
# (observed with Antigravity/Cascade hosts).
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
# Ensure the project root is on the path so gitea_auth.py can be imported.
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
import json
_preflight_whoami_called = False
_preflight_capability_called = False
_preflight_whoami_violation = False
_preflight_capability_violation = False
_preflight_resolved_role = None
_process_start_porcelain: str | None = None
_preflight_whoami_baseline_porcelain: str | None = None
_preflight_capability_baseline_porcelain: str | None = None
_preflight_whoami_violation_files: list[str] = []
_preflight_capability_violation_files: list[str] = []
_preflight_reviewer_violation_files: list[str] = []
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
def _preflight_in_test_mode() -> bool:
return "pytest" in sys.modules or "unittest" in sys.modules
def _ensure_process_start_porcelain() -> str:
"""Capture the shared-worktree baseline once per MCP process (#252)."""
global _process_start_porcelain
if _process_start_porcelain is None:
_process_start_porcelain = _get_workspace_porcelain()
return _process_start_porcelain
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
"""Resolve the workspace root inspected by pre-flight guards."""
path = (worktree_path or "").strip()
if not path:
path = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip()
if not path:
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
if not path:
path = PROJECT_ROOT
return os.path.realpath(os.path.abspath(path))
def _get_git_root(path: str) -> str | None:
try:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
if res.returncode != 0:
return None
return (res.stdout or "").strip() or None
def _get_workspace_porcelain(worktree_path: str | None = None) -> str:
"""Return tracked-workspace porcelain for pre-flight attribution."""
if os.environ.get("GITEA_TEST_FORCE_DIRTY"):
return " M __gitea_test_force_dirty__.py\n"
override = os.environ.get("GITEA_TEST_PORCELAIN")
if override is not None:
return override
workspace = _resolve_preflight_workspace_path(worktree_path)
try:
res = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
cwd=workspace,
)
return res.stdout or ""
except Exception:
return ""
def _parse_porcelain_entries(porcelain: str) -> dict[str, str]:
"""Map tracked path -> full porcelain line (untracked ``??`` ignored)."""
entries: dict[str, str] = {}
for line in (porcelain or "").splitlines():
if not line or len(line) < 4 or line.startswith("??"):
continue
path = line[3:].strip()
if " -> " in path:
path = path.split(" -> ", 1)[1].strip()
if path:
entries[path] = line
return entries
def _new_tracked_changes_since(baseline: str, current: str) -> list[str]:
"""Tracked paths that are new or changed since *baseline* porcelain."""
base = _parse_porcelain_entries(baseline)
cur = _parse_porcelain_entries(current)
changed = [
path for path, line in cur.items()
if path not in base or base[path] != line
]
return sorted(changed)
def _format_preflight_files(files: list[str]) -> str:
if not files:
return "(none)"
return ", ".join(files)
def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
workspace = _resolve_preflight_workspace_path(worktree_path)
inspected_root = _get_git_root(workspace)
control_root = os.path.realpath(PROJECT_ROOT)
active_root = os.path.realpath(inspected_root or workspace)
if active_root == control_root:
dirty_scope = "control checkout"
else:
dirty_scope = "active task workspace"
return {
"mcp_server_process_root": control_root,
"active_task_workspace_root": active_root,
"inspected_git_root": inspected_root,
"dirty_files": list(dirty_files),
"dirty_scope": dirty_scope,
}
def _format_preflight_workspace_details(details: dict) -> str:
return (
f"MCP server process root: {details.get('mcp_server_process_root')}; "
f"active task workspace root: {details.get('active_task_workspace_root')}; "
f"inspected git root: {details.get('inspected_git_root')}; "
f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}; "
f"dirty scope: {details.get('dirty_scope')}"
)
def assess_preflight_status(worktree_path: str | None = None) -> dict:
"""Non-throwing pre-flight readiness for runtime-context alignment (#252)."""
reasons: list[str] = []
if not _preflight_whoami_called:
reasons.append(
"Identity (gitea_whoami) has not been verified"
)
if not _preflight_capability_called:
reasons.append(
"Task capability (gitea_resolve_task_capability) has not been resolved"
)
workspace_details = None
if worktree_path:
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
workspace_details = _preflight_workspace_details(worktree_path, dirty_files)
if dirty_files:
reasons.append(
"Active task workspace has tracked file edits before mutation "
f"({_format_preflight_workspace_details(workspace_details)})"
)
return {
"preflight_ready": not reasons,
"preflight_block_reasons": reasons,
"preflight_whoami_verified": _preflight_whoami_called,
"preflight_capability_resolved": _preflight_capability_called,
"preflight_whoami_violation_files": [],
"preflight_capability_violation_files": [],
"preflight_reviewer_violation_files": [],
"preflight_workspace": workspace_details,
}
if _preflight_whoami_violation:
reasons.append(
"Workspace file edits occurred before gitea_whoami verification "
f"(offending files: {_format_preflight_files(_preflight_whoami_violation_files)})"
)
if _preflight_capability_violation:
reasons.append(
"Workspace file edits occurred before gitea_resolve_task_capability verification "
f"(offending files: {_format_preflight_files(_preflight_capability_violation_files)})"
)
if _preflight_reviewer_violation_files:
reasons.append(
"Reviewer profile modified tracked workspace files after capability resolution "
f"(offending files: {_format_preflight_files(_preflight_reviewer_violation_files)})"
)
warnings: list[str] = []
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
_get_workspace_porcelain()
)
if agent_artifacts:
warnings.append(
"Agent temp artifacts detected at repo root (delete before mutations): "
f"{_format_preflight_files(agent_artifacts)}"
)
return {
"preflight_ready": not reasons,
"preflight_block_reasons": reasons,
"preflight_warnings": warnings,
"preflight_whoami_verified": _preflight_whoami_called,
"preflight_capability_resolved": _preflight_capability_called,
"preflight_whoami_violation_files": list(_preflight_whoami_violation_files),
"preflight_capability_violation_files": list(_preflight_capability_violation_files),
"preflight_reviewer_violation_files": list(_preflight_reviewer_violation_files),
"preflight_workspace": _preflight_workspace_details(None, []),
}
def record_preflight_check(type_name: str, resolved_role: str | None = None):
"""Record a pre-flight check (whoami or capability) with session-scoped deltas."""
global _preflight_whoami_called, _preflight_capability_called
global _preflight_whoami_violation, _preflight_capability_violation
global _preflight_resolved_role
global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain
global _preflight_whoami_violation_files, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
current = _get_workspace_porcelain()
if type_name == "whoami":
# Fresh whoami restarts the capability step and re-evaluates violations
# instead of replaying a sticky record (#252).
_preflight_capability_called = False
_preflight_capability_violation = False
_preflight_capability_violation_files = []
_preflight_capability_baseline_porcelain = None
_preflight_reviewer_violation_files = []
process_start = _ensure_process_start_porcelain()
whoami_delta = _new_tracked_changes_since(process_start, current)
_preflight_whoami_violation = bool(whoami_delta)
_preflight_whoami_violation_files = whoami_delta
_preflight_whoami_baseline_porcelain = current
_preflight_whoami_called = True
elif type_name == "capability":
baseline = _preflight_whoami_baseline_porcelain or ""
capability_delta = _new_tracked_changes_since(baseline, current)
_preflight_capability_violation = bool(capability_delta)
_preflight_capability_violation_files = capability_delta
_preflight_capability_baseline_porcelain = current
_preflight_capability_called = True
if resolved_role:
_preflight_resolved_role = resolved_role
def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None):
"""Verify that identity and capability were verified prior to session edits."""
global _preflight_reviewer_violation_files
in_test = _preflight_in_test_mode()
if in_test and not (
os.environ.get("GITEA_TEST_FORCE_DIRTY")
or os.environ.get("GITEA_TEST_PORCELAIN") is not None
):
return
if not _preflight_whoami_called:
raise RuntimeError(
"Pre-flight order violation: Identity (gitea_whoami) has not been verified (fail closed)"
)
if not _preflight_capability_called:
raise RuntimeError(
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
if worktree_path:
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
if dirty_files:
details = _preflight_workspace_details(worktree_path, dirty_files)
raise RuntimeError(
"Pre-flight order violation: Active task workspace has tracked "
"file edits before mutation (fail closed). "
f"{_format_preflight_workspace_details(details)}"
)
return
if _preflight_whoami_violation:
raise RuntimeError(
"Pre-flight order violation: Workspace file edits occurred before "
f"gitea_whoami verification (fail closed). Offending files: "
f"{_format_preflight_files(_preflight_whoami_violation_files)}"
)
if _preflight_capability_violation:
raise RuntimeError(
"Pre-flight order violation: Workspace file edits occurred before "
f"gitea_resolve_task_capability verification (fail closed). Offending files: "
f"{_format_preflight_files(_preflight_capability_violation_files)}"
)
if _preflight_resolved_role == "reviewer":
current = _get_workspace_porcelain()
baseline = _preflight_capability_baseline_porcelain or ""
reviewer_delta = _new_tracked_changes_since(baseline, current)
_preflight_reviewer_violation_files = reviewer_delta
if reviewer_delta:
raise RuntimeError(
"Reviewer role violation: Reviewer profile is forbidden from modifying "
"tracked workspace files (fail closed). Offending files: "
f"{_format_preflight_files(reviewer_delta)}"
)
from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402
REMOTES,
get_credentials,
get_auth_header,
api_request,
api_get_all,
api_fetch_page,
repo_api_url,
get_profile,
gitea_url,
)
import gitea_audit # noqa: E402
import gitea_config # noqa: E402
import capability_stop_terminal # noqa: E402
import issue_duplicate_gate # noqa: E402
import role_session_router # noqa: E402
import role_namespace_gate # noqa: E402
import task_capability_map # noqa: E402
import review_proofs # noqa: E402
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402
import merged_cleanup_reconcile # noqa: E402
import review_merge_state_machine # noqa: E402
import native_mcp_preference # noqa: E402
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
# consumed by gitea_create_pr and scripts/worktree-start.
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
def _reveal_endpoints() -> bool:
"""Admin/debug opt-in (#120): include endpoint URLs and token source
names in tool output. Off by default so normal LLM-facing responses
expose only logical names and status. Never affects token values, which
are excluded on every path."""
return (os.environ.get("GITEA_MCP_REVEAL_ENDPOINTS") or "").strip().lower() \
in ("1", "true", "yes")
def _with_optional_url(result: dict, url: str | None) -> dict:
"""Attach web links only under the explicit endpoint reveal opt-in.
Mutates *result* in place and returns it; pass a freshly-built dict,
never a shared/aliased one.
"""
if _reveal_endpoints() and url:
result["url"] = url
return result
mcp = FastMCP("gitea-tools", instructions=(
"Gitea issue tracker and PR management for dadeschools and prgs instances. "
"Use the gitea_ prefixed tools to create issues, PRs, list issues, etc."
))
def extract_linked_issue_numbers(text: str | None, branch_name: str | None = None) -> list[int]:
issues = set()
if text:
pattern = re.compile(r'(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|implement[s]?|implemented)\s+#(\d+)')
issues.update(int(m) for m in pattern.findall(text))
if branch_name:
pattern = re.compile(r'(?i)issue-(\d+)')
issues.update(int(m) for m in pattern.findall(branch_name))
return sorted(list(issues))
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
if not issue_numbers:
return {}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
try:
labels = api_request("GET", f"{base}/labels?limit=100", auth)
label_id = None
for lb in labels:
if lb["name"] == "status:in-progress":
label_id = lb["id"]
break
except Exception as exc:
return {num: f"error fetching repo labels: {_redact(str(exc))}" for num in issue_numbers}
results = {}
if label_id is None:
for num in issue_numbers:
results[num] = "not present"
return results
for num in issue_numbers:
try:
url = f"{base}/issues/{num}"
issue_data = api_request("GET", url, auth)
issue_labels = [lb["name"] for lb in issue_data.get("labels", [])]
if "status:in-progress" in issue_labels:
with _audited("release_in_progress_label", host=h, remote=remote, org=o, repo=r, issue_number=num, request_metadata={"action": "remove status:in-progress"}):
api_request("DELETE", f"{url}/labels/{label_id}", auth)
results[num] = "released"
else:
results[num] = "not present"
except Exception as exc:
results[num] = f"error: {_redact(str(exc))}"
return results
def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
body = pr_payload.get("body") or ""
title = pr_payload.get("title") or ""
branch = pr_payload.get("head", {}).get("ref") or ""
text = f"{title}\n{body}"
issues = extract_linked_issue_numbers(text, branch)
if not issues:
return {"cleanup_status": "no linked issue found"}
results = release_in_progress_label(issues, remote, host, org, repo)
return {"cleanup_status": results}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
"""Resolve remote + overrides to (host, org, repo)."""
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
profile = REMOTES[remote]
return (
host or profile["host"],
org or profile["org"],
repo or profile["repo"],
)
def _auth(host: str) -> str:
"""Get auth header, raise if unavailable."""
header = get_auth_header(host)
if header is None:
raise RuntimeError(
f"No credentials for {host}. "
"Ensure you've logged in via HTTPS at least once."
)
return header
# ── Audit logging (#18) ─────────────────────────────────────────────────────────
# Mutating actions emit a structured audit record (profile + authenticated user
# + outcome) when GITEA_AUDIT_LOG is configured. When it is not, every helper
# below short-circuits and performs NO work — no extra API calls, no I/O — so
# existing tool behaviour and API call sequences are unchanged.
_UNSET = object()
# Best-effort identity cache keyed by host, so an enabled audit trail resolves
# the authenticated username at most once per host per process.
_IDENTITY_CACHE: dict = {}
def _authenticated_username(host: str):
"""Resolve the authenticated Gitea username for *host* (cached, fail-soft).
Read-only. Returns None if the identity cannot be determined; never raises
and never surfaces credential material.
"""
if host in _IDENTITY_CACHE:
return _IDENTITY_CACHE[host]
user = None
try:
header = get_auth_header(host)
if header:
who = api_request("GET", gitea_url(host, "/api/v1/user"), header)
user = (who or {}).get("login")
except Exception:
user = None
_IDENTITY_CACHE[host] = user
return user
def _ensure_matching_profile(required_permission: str, required_role: str, remote: str | None, host: str | None = None) -> str | None:
"""Check if the active profile is allowed to perform *required_permission*.
If not, automatically switch to the first matching usable configured profile.
"""
try:
profile = get_profile()
except Exception:
return None
active_profile = profile.get("profile_name")
active_allowed = profile.get("allowed_operations") or []
active_forbidden = profile.get("forbidden_operations") or []
allowed, _ = gitea_config.check_operation(required_permission, active_allowed, active_forbidden)
if allowed:
return active_profile
# Try to find a matching usable profile in config
if gitea_config.is_runtime_switching_enabled():
config = gitea_config.load_config()
if config and "profiles" in config:
for p_name, p_data in config["profiles"].items():
p_allowed = p_data.get("allowed_operations") or []
p_forbidden = p_data.get("forbidden_operations") or []
p_allowed_n = []
for op in p_allowed:
try:
p_allowed_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
p_forbidden_n = []
for op in p_forbidden:
try:
p_forbidden_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
if ok:
# Verify credentials/token are available
try:
tok = gitea_config.resolve_token(p_data)
if tok:
# Perform automatic switch
gitea_config._active_profile_override = p_name
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
if h:
_IDENTITY_CACHE.pop(h, None)
username = _authenticated_username(h) if h else None
# Update mutation authority
global _MUTATION_AUTHORITY
if _MUTATION_AUTHORITY is not None:
_MUTATION_AUTHORITY["current_profile"] = p_name
_MUTATION_AUTHORITY["current_identity"] = username
_MUTATION_AUTHORITY["role_pivot_authorized"] = True
return p_name
except Exception:
pass
return None
def _audit(action: str, *, host, remote, result, org=None, repo=None,
reason=None, request_metadata=None, issue_number=None,
pr_number=None, target_branch=None, head_sha=None, username=_UNSET,
mutation_task: str | None = None):
"""Emit one audit record for a mutating action. No-op unless auditing is on.
Never raises — auditing must not break the action it records.
"""
if not gitea_audit.audit_enabled():
return
try:
profile = get_profile()
if username is _UNSET:
username = _authenticated_username(host) if host else None
ns_ctx = {}
if mutation_task:
ns_ctx = role_namespace_gate.mutation_audit_context(
mutation_task, profile, remote=remote, repository=repo)
event = gitea_audit.build_event(
action=action,
result=result,
remote=remote,
server=(gitea_url(host, "").rstrip("/") if host else None),
repository=repo,
issue_number=issue_number,
pr_number=pr_number,
profile_name=profile["profile_name"],
audit_label=profile["audit_label"],
authenticated_username=username,
target_branch=target_branch,
head_sha=head_sha,
reason=reason,
request_metadata=request_metadata,
mcp_namespace=ns_ctx.get("mcp_namespace"),
task_role=ns_ctx.get("task_role"),
operation=ns_ctx.get("operation"),
)
gitea_audit.write_event(event)
except Exception:
pass # best-effort; never break the action
@contextlib.contextmanager
def _audited(action: str, *, host, remote, org=None, repo=None,
request_metadata=None, issue_number=None, pr_number=None,
target_branch=None):
"""Wrap a mutating API call: audit SUCCEEDED on return, FAILED on exception.
When auditing is off this yields immediately with no bookkeeping.
"""
if not gitea_audit.audit_enabled():
yield
return
try:
yield
except Exception as exc:
_audit(action, host=host, remote=remote, org=org, repo=repo,
result=gitea_audit.FAILED, reason=_redact(str(exc)),
request_metadata=request_metadata, issue_number=issue_number,
pr_number=pr_number, target_branch=target_branch)
raise
_audit(action, host=host, remote=remote, org=org, repo=repo,
result=gitea_audit.SUCCEEDED, request_metadata=request_metadata,
issue_number=issue_number, pr_number=pr_number,
target_branch=target_branch)
def _audit_pr_result(action: str):
"""Decorator for gated PR tools that return a result dict.
Reads the tool's own result dict (authenticated_user, profile, reasons,
performed) to emit an audit record classifying the outcome as SUCCEEDED,
BLOCKED, or FAILED. No extra API calls: identity comes from the result.
"""
def decorate(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
try:
if isinstance(result, dict) and gitea_audit.audit_enabled():
reasons = [str(x) for x in (result.get("reasons") or [])]
if result.get("performed"):
status = gitea_audit.SUCCEEDED
elif any("failed:" in x.lower() for x in reasons):
# "failed:" marks a surfaced exception (e.g. "merge
# failed: <err>"); a bare gate message like "… failed
# (fail closed)" is a policy block, not an execution error.
status = gitea_audit.FAILED
else:
status = gitea_audit.BLOCKED
remote = result.get("remote")
host = REMOTES[remote]["host"] if remote in REMOTES else None
_audit(
action,
host=host,
remote=remote,
result=status,
reason="; ".join(reasons) or None,
pr_number=result.get("pr_number"),
head_sha=result.get("head_sha"),
username=result.get("authenticated_user"),
request_metadata={
"requested_action": result.get("requested_action"),
"merge_method": result.get("merge_method"),
},
)
except Exception:
pass # best-effort; never break the tool
return result
return wrapper
return decorate
# ── Tools ─────────────────────────────────────────────────────────────────────
@mcp.tool()
def gitea_create_issue(
title: str,
body: str = "",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
allow_duplicate_override: bool = False,
split_from_issue: int | None = None,
) -> dict:
"""Create a new issue on a Gitea repository.
Args:
title: Issue title (required).
body: Issue body text.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
allow_duplicate_override: Operator-approved split after duplicate found.
split_from_issue: Existing duplicate issue number when overriding.
Returns:
dict with 'number' of the created issue ('url' only with the reveal opt-in).
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
"create_issue"
)
if not ok:
return {
"success": False,
"performed": False,
"number": None,
"reasons": block_reasons,
}
blocked = _namespace_mutation_block(
"create_issue", number=None, remote=remote)
if blocked:
return blocked
blocked = _profile_permission_block(
task_capability_map.required_permission("create_issue"),
number=None,
)
if blocked:
return blocked
verify_preflight_purity(remote)
base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
f"{base}/issues?state=closed&type=issues", auth, limit=100
)
gate = issue_duplicate_gate.pre_create_issue_duplicate_gate(
title,
list(open_issues) + list(closed_issues),
allow_override=allow_duplicate_override,
split_from_issue=split_from_issue,
)
if not gate.get("performed"):
return {
"success": False,
"performed": False,
"number": None,
"duplicate_gate": gate["verdict"],
"matches": gate.get("matches", []),
"reasons": gate.get("reasons", []),
}
issue_body = body
if gate.get("override_applied") and split_from_issue is not None:
rel = f"Operator-approved split from #{split_from_issue}."
issue_body = f"{rel}\n\n{body}" if body else rel
url = f"{base}/issues"
try:
data = api_request("POST", url, auth, {"title": title, "body": issue_body})
except Exception as exc:
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.FAILED, reason=_redact(str(exc)),
request_metadata={"title": title})
raise
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
request_metadata={"title": title}, mutation_task="create_issue")
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
@mcp.tool()
def gitea_lock_issue(
issue_number: int,
branch_name: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
Args:
issue_number: The tracking issue number.
branch_name: The branch name (must match (fix|feat|docs|chore)/issue-<issue_number>-<desc>).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override Gitea host.
org: Override Org.
repo: Override Repo.
worktree_path: Author scratch-clone path to validate (defaults to
GITEA_AUTHOR_WORKTREE or the MCP server project root).
"""
# 1. Enforce branch name includes issue number
expected_pattern = f"issue-{issue_number}"
if expected_pattern not in branch_name:
raise ValueError(
f"Branch name '{branch_name}' must contain locked issue pattern '{expected_pattern}' (fail closed)"
)
blocked = _profile_permission_block(
task_capability_map.required_permission("lock_issue"))
if blocked:
return blocked
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, PROJECT_ROOT
)
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
verify_preflight_purity(remote, worktree_path=resolved_worktree)
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=resolved_worktree,
current_branch=git_state.get("current_branch"),
porcelain_status=git_state.get("porcelain_status") or "",
base_equivalent=git_state.get("base_equivalent"),
inspected_git_root=git_state.get("inspected_git_root"),
base_branch=git_state.get("base_branch"),
)
if lock_assessment["block"]:
raise RuntimeError(
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
)
# 2. Check if the issue already has an open PR (reuse protection)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
try:
prs = api_get_all(url, auth)
except Exception as e:
raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}")
for pr in prs:
pr_head = pr.get("head", {}).get("ref", "")
pr_title = pr.get("title", "")
pr_body = pr.get("body", "")
if expected_pattern in pr_head:
raise ValueError(
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}, branch '{pr_head}') (fail closed)"
)
patterns = [
f"closes #{issue_number}",
f"fixes #{issue_number}",
]
text_to_check = f"{pr_title} {pr_body}".lower()
if any(p in text_to_check for p in patterns):
raise ValueError(
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)"
)
data = {
"issue_number": issue_number,
"branch_name": branch_name,
"remote": remote,
"org": o,
"repo": r,
"worktree_path": resolved_worktree,
}
try:
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception as e:
raise RuntimeError(f"Could not write issue lock file: {e}")
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
git_state.get("porcelain_status") or ""
)
result = {
"success": True,
"message": (
f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
f"from worktree '{resolved_worktree}' (fail-closed check complete)."
),
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": resolved_worktree,
}
if agent_artifacts:
result["warnings"] = [
"Agent temp artifacts at repo root (delete before implementation): "
+ ", ".join(agent_artifacts)
]
return result
@mcp.tool()
def gitea_create_pr(
title: str,
head: str,
base: str = "main",
body: str = "",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Create a pull request on a Gitea repository.
Args:
title: PR title (required).
head: Source branch name (required).
base: Target branch (default: 'main').
body: PR description.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Author worktree path; must match the path stored at lock time.
Returns:
dict with 'number' of the created PR ('url' only with the reveal opt-in).
"""
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
"create_pr")
if not ok:
return {
"success": False,
"performed": False,
"number": None,
"reasons": block_reasons,
}
blocked = _namespace_mutation_block(
"create_pr", number=None, remote=remote)
if blocked:
return blocked
blocked = _profile_permission_block(
task_capability_map.required_permission("create_pr"),
number=None,
)
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
# ── Issue Lock Validation (Issue #194 / #196) ──
if not os.path.exists(ISSUE_LOCK_FILE):
raise RuntimeError("Issue lock is missing (fail closed). Call gitea_lock_issue first.")
try:
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
except Exception as e:
raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
locked_issue = lock_data.get("issue_number")
locked_branch = lock_data.get("branch_name")
locked_worktree = lock_data.get("worktree_path")
worktree_check = issue_lock_worktree.verify_pr_worktree_matches_lock(
locked_worktree, worktree_path, PROJECT_ROOT
)
if worktree_check["block"]:
raise ValueError(worktree_check["reasons"][0])
if head != locked_branch:
raise ValueError(
f"PR head branch '{head}' does not match locked branch '{locked_branch}' (fail closed)"
)
# Check for forbidden terms anywhere in title/body
forbidden_terms = ["equivalent", "related", "same as"]
text_to_check = f"{title} {body}".lower()
for term in forbidden_terms:
if term in text_to_check:
raise ValueError(
f"PR title or body contains forbidden term '{term}' (fail closed)"
)
# Ensure Closes #<locked_issue> or Fixes #<locked_issue> is present exactly
closes_pattern = re.compile(rf"\b(closes|fixes)\s+#{locked_issue}\b", re.IGNORECASE)
if not closes_pattern.search(text_to_check):
raise ValueError(
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base}
meta = {"title": title, "head": head, "base": base}
try:
data = api_request("POST", url, auth, payload)
except Exception as exc:
_audit("create_pr", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.FAILED, reason=_redact(str(exc)),
target_branch=head, request_metadata=meta,
mutation_task="create_pr")
raise
_audit("create_pr", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
target_branch=head, request_metadata=meta,
mutation_task="create_pr")
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
def _format_list_pr_entry(pr: dict) -> dict:
head_obj = pr.get("head") or {}
base_obj = pr.get("base") or {}
entry = {
"number": pr["number"],
"title": pr["title"],
"state": pr["state"],
"head": head_obj.get("ref") if isinstance(head_obj, dict) else head_obj,
"base": base_obj.get("ref") if isinstance(base_obj, dict) else base_obj,
"mergeable": pr.get("mergeable"),
"updated_at": pr.get("updated_at"),
"head_sha": head_obj.get("sha") if isinstance(head_obj, dict) else None,
"author": (pr.get("user") or {}).get("login"),
"labels": [
label["name"]
for label in pr.get("labels", [])
if isinstance(label, dict) and label.get("name")
],
"body": pr.get("body") or "",
}
return _with_optional_url(entry, pr.get("html_url"))
def _list_prs_pagination_envelope(
prs: list[dict],
*,
page: int,
per_page: int,
pages_fetched: int,
is_final_page: bool,
has_more: bool,
next_page: int | None,
inventory_complete: bool,
) -> dict:
return {
"prs": prs,
"pagination": {
"page": page,
"per_page": per_page,
"returned_count": len(prs),
"has_more": has_more,
"next_page": next_page,
"is_final_page": is_final_page,
"pages_fetched": pages_fetched,
"inventory_complete": inventory_complete,
"total_count": len(prs) if inventory_complete else None,
},
}
@mcp.tool()
def gitea_list_prs(
state: str = "open",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
page: int | None = None,
per_page: int = 50,
) -> dict:
"""List pull requests on a Gitea repository.
Args:
state: State filter — 'open', 'closed', or 'all' (default: 'open').
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
page: Optional 1-based page for explicit single-page fetch. When
omitted, all pages are traversed and ``inventory_complete`` is set.
per_page: Page size (150; Gitea caps at 50).
Returns:
Dict with ``prs`` (list of PR summaries) and ``pagination`` metadata
(``has_more``, ``next_page``, ``is_final_page``, ``inventory_complete``,
``pages_fetched``, ``total_count`` when complete). Each PR entry carries
``number``, ``title``, ``state``, ``head``, ``base``, ``mergeable``,
``updated_at`` ('url' only with the reveal opt-in).
"""
allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool(
"list_prs"
)
if not allowed:
raise RuntimeError("; ".join(block_reasons))
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
if page is not None:
raw_page, pagination = api_fetch_page(
url, auth, page=page, limit=per_page
)
prs = [_format_list_pr_entry(pr) for pr in raw_page]
return _list_prs_pagination_envelope(
prs,
page=pagination["page"],
per_page=pagination["per_page"],
pages_fetched=1,
is_final_page=pagination["is_final_page"],
has_more=pagination["has_more"],
next_page=pagination["next_page"],
inventory_complete=False,
)
all_raw: list[dict] = []
current_page = 1
pages_fetched = 0
last_meta: dict = {}
while pages_fetched < 100:
raw_page, last_meta = api_fetch_page(
url, auth, page=current_page, limit=per_page
)
pages_fetched += 1
all_raw.extend(raw_page)
if last_meta["is_final_page"]:
break
current_page += 1
prs = [_format_list_pr_entry(pr) for pr in all_raw]
return _list_prs_pagination_envelope(
prs,
page=1,
per_page=per_page,
pages_fetched=pages_fetched,
is_final_page=bool(last_meta.get("is_final_page")),
has_more=False,
next_page=None,
inventory_complete=bool(last_meta.get("is_final_page")),
)
@mcp.tool()
def gitea_view_pr(
pr_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Get details of a single pull request.
Args:
pr_number: The pull request index/number.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with PR details including 'updated_at', 'merged_at',
'merge_commit_sha', 'closed_at' (when present) to support
live-state reconciliation and staleness detection against
prior reports or handoffs.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
pr = api_request("GET", url, auth)
result = {
"number": pr["number"],
"title": pr["title"],
"body": pr.get("body", ""),
"state": pr["state"],
"head": pr["head"]["ref"],
"base": pr["base"]["ref"],
"mergeable": pr.get("mergeable"),
"user": pr.get("user", {}).get("login", ""),
"updated_at": pr.get("updated_at"),
"merged_at": pr.get("merged_at"),
"merge_commit_sha": pr.get("merge_commit_sha"),
"closed_at": pr.get("closed_at"),
}
return _with_optional_url(result, pr.get("html_url"))
# Actions whose eligibility this tool can evaluate.
_ELIGIBILITY_ACTIONS = ("review", "approve", "request_changes", "merge")
@mcp.tool()
def gitea_check_pr_eligibility(
pr_number: int,
action: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Read-only: is the current identity/profile eligible to perform *action*
on a PR?
Evaluates eligibility only — it NEVER reviews, approves, requests changes,
merges, or mutates anything. It inspects the authenticated identity
(via the /user endpoint), the active runtime profile metadata
(``get_profile``), and the target PR (author, state, head SHA,
mergeability), then returns a decision with clear reasons.
Fail-closed rules:
- Unknown action or unknown remote → not eligible.
- Profile has no configured allowed operations, or the action is not in
the profile's allowed operations (or is forbidden) → not eligible.
- Authenticated identity cannot be determined → not eligible.
- Authenticated user equals the PR author → not eligible to ``approve`` or
``merge``.
- PR is not open → not eligible.
- For ``merge``, PR must be reported mergeable.
Never returns the token, Authorization header, or any credential material.
Args:
pr_number: Target PR number.
action: One of 'review', 'approve', 'request_changes', 'merge'.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'eligible' (bool), the inputs inspected, and 'reasons';
when the block is a missing profile permission, also a structured
'permission_report' (#142).
"""
action = (action or "").strip().lower()
if action in ("review", "approve", "request_changes", "merge"):
allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool(
"check_pr_eligibility"
)
if not allowed:
return {
"eligible": False,
"requested_action": action,
"reasons": block_reasons,
"terminal_mode": True,
}
profile = get_profile()
result = {
"eligible": False,
"requested_action": action,
"authenticated_user": None,
"profile_name": profile["profile_name"],
"allowed_operations": profile["allowed_operations"],
"pr_author": None,
"pr_number": pr_number,
"pr_state": None,
"head_sha": None,
"mergeable": None,
"remote": remote if remote in REMOTES else None,
"reasons": [],
}
reasons = result["reasons"]
if action not in _ELIGIBILITY_ACTIONS:
reasons.append(
f"unknown action '{action}'; expected one of {list(_ELIGIBILITY_ACTIONS)}"
)
return result
if remote not in REMOTES:
reasons.append(f"unknown remote '{remote}'")
return result
# Profile capability check (metadata only; not enforcement of the action).
# Both the action and the profile lists are normalized before comparison
# (#106), so legacy spellings ("merge") and canonical namespaced ops
# ("gitea.pr.merge") always match each other and never cross services.
allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"]
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
if not op_ok:
if op_reason == "no-allowed-operations":
reasons.append(
"profile has no configured allowed operations (fail closed)")
elif op_reason == "forbidden":
reasons.append(f"profile forbids '{action}'")
elif op_reason == "invalid-forbidden-entry":
reasons.append(
"profile has an unrecognized forbidden operation entry "
"(fail closed)")
else:
reasons.append(f"profile is not allowed to {action}")
# Stop here immediately if the action implies reviewer role and the
# profile doesn't permit it. No identity/PR API calls on this path;
# the structured #142 report is built config-only (no network).
if action in ("review", "approve", "request_changes", "merge"):
result["eligible"] = False
missing_perm = (
f"gitea.pr.{action}"
if action in ("approve", "request_changes", "merge", "review")
else f"gitea.{action}")
result["missing_permission"] = missing_perm
result["active_profile"] = profile["profile_name"]
result["active_identity"] = None
result["permission_report"] = _permission_block_report(
missing_perm)
req_profile = None
if action in ("approve", "request_changes", "review"):
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
elif action == "merge":
req_profile = "A profile with reviewer role permissions and explicit merge permission"
result["required_profile"] = req_profile
switching_supported = gitea_config.is_runtime_switching_enabled()
if switching_supported:
result["fixable_by_profile_switch"] = True
result["requires_different_namespace"] = False
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
else:
result["fixable_by_profile_switch"] = False
result["requires_different_namespace"] = True
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
result["safe_next_step"] = safe_step
return result
h, o, r = _resolve(remote, host, org, repo)
# Authenticated identity (read-only). Fail soft; never leak error/secret.
try:
auth = _auth(h)
except Exception:
auth = None
auth_user = None
if auth:
try:
who = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
auth_user = (who or {}).get("login")
except Exception:
auth_user = None
result["authenticated_user"] = auth_user
if not auth_user:
reasons.append("authenticated identity could not be determined")
# PR facts (read-only GET; no mutation).
pr_author = None
pr_state = None
if auth:
try:
pr = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
)
pr_author = (pr or {}).get("user", {}).get("login")
pr_state = (pr or {}).get("state")
result["head_sha"] = ((pr or {}).get("head") or {}).get("sha")
result["mergeable"] = (pr or {}).get("mergeable")
except Exception:
reasons.append("PR details could not be retrieved")
else:
reasons.append("PR details could not be retrieved (no credentials)")
result["pr_author"] = pr_author
result["pr_state"] = pr_state
# PR must be open to act on.
if pr_state is None:
reasons.append("PR state unknown")
elif pr_state != "open":
reasons.append(f"PR is not open (state={pr_state})")
# Self-author must not approve or merge their own PR.
if auth_user and pr_author and auth_user == pr_author and action in ("approve", "merge"):
reasons.append("authenticated user is PR author")
# Merge needs a positive mergeability signal.
if action == "merge":
if result["mergeable"] is False:
reasons.append("PR is not mergeable")
elif result["mergeable"] is None:
reasons.append("PR mergeability unknown")
result["eligible"] = len(reasons) == 0
if result["eligible"]:
reasons.append("all eligibility checks passed")
# Add enhanced error clarity details (#131)
auth_user = result["authenticated_user"]
pr_author = result["pr_author"]
profile_name = result["profile_name"]
is_self = bool(auth_user and pr_author and auth_user == pr_author)
result["active_identity"] = auth_user
result["active_profile"] = profile_name
result["self_author"] = is_self
# Determine missing permission
missing_perm = None
if not op_ok:
missing_perm = (
f"gitea.pr.{action}"
if action in ("approve", "request_changes", "merge", "review")
else f"gitea.{action}")
result["missing_permission"] = missing_perm
if missing_perm:
# Structured denial guidance (#142) — read-only, never widens.
result["permission_report"] = _permission_block_report(
missing_perm, identity=auth_user)
# Determine required profile
req_profile = None
if action in ("approve", "request_changes", "review"):
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
elif action == "merge":
req_profile = "A profile with reviewer role permissions and explicit merge permission"
result["required_profile"] = req_profile
# Determine required identity
req_identity = None
if is_self:
req_identity = f"Any Gitea user other than PR author '{pr_author}'"
result["required_identity"] = req_identity
# Determine if fixable by profile switch vs requires different namespace
switching_supported = gitea_config.is_runtime_switching_enabled()
fixable_by_switch = False
req_different_ns = False
if not op_ok:
config = gitea_config.load_config()
has_capable_profile = False
if config:
for name, p in (config.get("profiles") or {}).items():
p_allowed = p.get("allowed_operations", [])
p_forbidden = p.get("forbidden_operations", [])
ok, _ = gitea_config.check_operation(action, p_allowed, p_forbidden)
if ok:
has_capable_profile = True
break
if switching_supported and has_capable_profile:
fixable_by_switch = True
else:
req_different_ns = True
result["fixable_by_profile_switch"] = fixable_by_switch
result["requires_different_namespace"] = req_different_ns
# Determine safe next step
safe_step = "Ready."
if not result["eligible"]:
if is_self:
safe_step = "Self-review/self-merge is forbidden. Ask a different operator/reviewer to review and merge this PR."
elif not auth_user:
safe_step = f"Ask the operator to configure valid credentials/token for profile '{profile_name}'."
elif not op_ok:
if fixable_by_switch:
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
else:
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
elif result["pr_state"] != "open":
safe_step = "No action can be taken on a closed/merged PR."
elif action == "merge":
if result["mergeable"] is False:
safe_step = "Address conflicts in the PR branches first, or wait for CI checks to pass before merging."
elif result["mergeable"] is None:
safe_step = "Wait for Gitea to finish calculating mergeability or trigger a re-check."
result["safe_next_step"] = safe_step
return result
# Review actions this gated tool can perform, mapped to (eligibility action,
# Gitea review *event*). The eligibility action is fed to
# ``gitea_check_pr_eligibility`` (#14) so every mutation reuses the same
# identity/profile/author gates. Note: 'merge' is deliberately absent — merge
# belongs to a separate tool/issue and is never performed here.
_REVIEW_ACTIONS = {
# 'comment' posts review findings without an approval/rejection state.
# #14 names this eligibility category 'review'.
"comment": ("review", "COMMENT"),
# Gitea ReviewStateType uses APPROVED, not APPROVE — wrong event leaves PENDING (#244).
"approve": ("approve", "APPROVED"),
"request_changes": ("request_changes", "REQUEST_CHANGES"),
}
_TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
# In-process only (#211): never persist to /tmp — host-global files are
# spoofable by any local process and go stale across sessions.
_REVIEW_DECISION_LOCK: dict | None = None
def _load_review_decision_lock():
global _REVIEW_DECISION_LOCK
return _REVIEW_DECISION_LOCK
def _save_review_decision_lock(data):
global _REVIEW_DECISION_LOCK
_REVIEW_DECISION_LOCK = data
def _review_decision_session_reasons(lock: dict | None) -> list[str]:
"""Reject locks that do not belong to this MCP process/session."""
if lock is None:
return []
reasons = []
if lock.get("session_pid") != os.getpid():
reasons.append(
"review decision lock was created in a different process "
"(fail closed)"
)
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
stored_lock = (lock.get("session_profile_lock") or "").strip()
if env_lock and stored_lock and env_lock != stored_lock:
reasons.append(
"review decision lock session profile lock mismatch (fail closed)"
)
return reasons
def init_review_decision_lock(remote: str | None, task: str | None):
"""Seed read-only-until-ready state for reviewer PR review tasks."""
if task != "review_pr":
return
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip()
session_lock = (
(os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
or profile_name
)
_save_review_decision_lock({
"task": task,
"remote": remote,
"session_pid": os.getpid(),
"session_profile": profile_name,
"session_profile_lock": session_lock,
"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": [],
"correction_authorized": False,
"correction_reason": None,
})
def check_review_decision_gate(
pr_number: int,
action: str,
*,
final_review_decision_ready: bool,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list[str]:
"""Fail closed unless validation completed and the final decision is ready."""
reasons = []
lock = _load_review_decision_lock()
if lock is None:
reasons.append(
"review decision lock missing; call gitea_resolve_task_capability "
"for review_pr before live review mutations (fail closed)"
)
return reasons
reasons.extend(_review_decision_session_reasons(lock))
if reasons:
return reasons
if remote in REMOTES:
_, org, repo = _resolve(remote, None, org, repo)
if not final_review_decision_ready:
reasons.append(
"final_review_decision_ready must be true; validation-phase live "
"review mutations are forbidden — use gitea_dry_run_pr_review "
"instead (fail closed)"
)
return reasons
if not lock.get("final_review_decision_ready"):
reasons.append(
"final review decision not marked ready; call "
"gitea_mark_final_review_decision after validation completes "
"(fail closed)"
)
return reasons
if lock.get("ready_pr_number") != pr_number:
reasons.append(
f"ready PR #{lock.get('ready_pr_number')} does not match "
f"requested PR #{pr_number} (fail closed)"
)
if lock.get("ready_action") != action:
reasons.append(
f"ready action '{lock.get('ready_action')}' does not match "
f"requested action '{action}' (fail closed)"
)
if lock.get("ready_remote") != remote:
reasons.append(
f"ready remote '{lock.get('ready_remote')}' does not match "
f"requested remote '{remote}' (fail closed)"
)
if lock.get("ready_org") != org:
reasons.append(
f"ready org '{lock.get('ready_org')}' does not match "
f"requested org '{org}' (fail closed)"
)
if lock.get("ready_repo") != repo:
reasons.append(
f"ready repo '{lock.get('ready_repo')}' does not match "
f"requested repo '{repo}' (fail closed)"
)
prior = list(lock.get("live_mutations") or [])
if prior and not lock.get("correction_authorized"):
reasons.append(
"live review mutation already recorded in this run; only one live "
"review mutation is allowed unless "
"gitea_authorize_review_correction was invoked (fail closed)"
)
elif (
action in _TERMINAL_REVIEW_ACTIONS
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
and not lock.get("correction_authorized")
):
reasons.append(
"terminal review decision already submitted on this PR in this "
"run; blocked unless an operator-approved correction was "
"authorized (fail closed)"
)
return reasons
def record_live_review_mutation(pr_number: int, action: str, review_id: int | None = None):
lock = _load_review_decision_lock() or {}
mutations = list(lock.get("live_mutations") or [])
mutations.append({
"pr_number": pr_number,
"action": action,
"review_id": review_id,
"review_state": action,
})
lock["live_mutations"] = mutations
if lock.get("correction_authorized"):
lock["correction_authorized"] = False
lock["correction_reason"] = None
_save_review_decision_lock(lock)
def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[str]:
"""Session hard-stop after a terminal live review mutation (#332).
After a terminal verdict is consumed in this run, the only permitted
continuation is the merge sequence for the same PR that was approved.
A REQUEST_CHANGES (or an approval of a different PR) blocks every
further review/mark-ready/merge mutation. An operator-approved
correction (#211) re-opens the review path only — never a cross-PR
merge.
*operation* is one of 'merge', 'mark_ready', or 'review'.
Returns [] when the operation may proceed.
"""
lock = _load_review_decision_lock()
if lock is None:
return []
terminals = [
m for m in (lock.get("live_mutations") or [])
if m.get("action") in _TERMINAL_REVIEW_ACTIONS
]
if not terminals:
return []
last = terminals[-1]
if (
operation == "merge"
and last.get("action") == "approve"
and last.get("pr_number") == pr_number
):
return []
if operation in ("mark_ready", "review") and lock.get("correction_authorized"):
return []
if last.get("action") == "approve":
guidance = (
f"only the merge sequence for approved PR "
f"#{last.get('pr_number')} may continue"
)
else:
guidance = "the session must stop and produce a final report"
return [
"terminal review mutation already consumed in this run "
f"({last.get('action')} on PR #{last.get('pr_number')}); {guidance} "
"(fail closed, #332)"
]
# Patterns scrubbed from any surfaced error text so a credential can never leak.
_SECRET_PREFIXES = ("token ", "Basic ")
def _redact(text: str) -> str:
"""Strip anything that looks like an Authorization credential or raw URL from *text*.
Errors raised by ``api_request`` echo the server response body, not the
request headers, so a token should never appear — this is defence in depth
so a future change can't leak ``token …`` / ``Basic …`` material into a
tool result or log line.
"""
if not text:
return text
out = text
for prefix in _SECRET_PREFIXES:
idx = 0
while True:
i = out.find(prefix, idx)
if i == -1:
break
j = i + len(prefix)
while j < len(out) and not out[j].isspace():
j += 1
out = out[:i] + prefix + "[REDACTED]" + out[j:]
idx = i + len(prefix) + len("[REDACTED]")
# Redact raw URLs, query secrets, hostnames, etc.
import gitea_audit
return gitea_audit.redact_urls(out)
# Review states that carry a submitted verdict. Gitea also emits PENDING
# (draft) and REQUEST_REVIEW (re-review ask); neither is a verdict and
# neither may drive the blocking/approval summaries.
_VERDICT_STATES = ("APPROVED", "REQUEST_CHANGES", "COMMENT")
# Terminal review events that must be visible after live submission (#244).
_SUBMIT_VISIBLE_EVENTS = frozenset({"APPROVED", "REQUEST_CHANGES"})
def _latest_review_state_for_reviewer(raw_reviews: list, reviewer: str) -> str | None:
"""Return the latest non-COMMENT verdict for *reviewer*, or None."""
ordered = sorted(
raw_reviews or [],
key=lambda rv: ((rv.get("submitted_at") or ""), rv.get("id") or 0),
)
latest = None
for rv in ordered:
state = (rv.get("state") or "").upper()
login = (rv.get("user") or {}).get("login", "")
if login != reviewer or state not in _VERDICT_STATES or state == "COMMENT":
continue
latest = state
return latest
def _submit_pending_pull_review(
h: str,
o: str,
r: str,
pr_number: int,
review_id: int,
event: str,
body: str,
auth,
) -> dict | None:
"""Submit a PENDING draft review via Gitea's pending-review endpoint."""
submit_url = (
f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews/{review_id}"
)
return api_request("POST", submit_url, auth, {"body": body, "event": event})
@mcp.tool()
def gitea_get_pr_review_feedback(
pr_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Read-only: discover formal PR review feedback through MCP (#167).
Formal review verdicts (APPROVED / REQUEST_CHANGES / COMMENT) live on
Gitea's review endpoints, not in the issue-comment thread. This tool is
the MCP-native way to read them, so an LLM never has to infer review
state from issue comments. It never submits reviews and never mutates
anything; the profile must allow ``gitea.read`` (fail closed otherwise,
with a structured #142 permission report and no API call made).
Endpoints: ``GET /repos/{owner}/{repo}/pulls/{n}`` (current head SHA)
and ``GET /repos/{owner}/{repo}/pulls/{n}/reviews``.
Normal output is LLM-safe: review bodies pass through credential
redaction and no endpoint URLs appear. Set GITEA_MCP_REVEAL_ENDPOINTS=1
(admin/debug opt-in) to include each review's web link.
Args:
pr_number: The pull request index/number.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success', 'pr_number', 'pr_state', 'current_head_sha',
'reviews' (each: reviewer, verdict, body, submitted_at,
reviewed_head_sha, dismissed, stale),
'latest_review_state_by_reviewer' (latest non-COMMENT verdict per
reviewer; PENDING drafts never count),
'has_blocking_change_requests' (a reviewer's latest verdict is an
undismissed REQUEST_CHANGES), 'approval_visible',
'latest_reviewed_head_sha' (head at the most recent
APPROVED/REQUEST_CHANGES review; COMMENT-only reviews never
advance it), 'review_feedback_stale' (that verdict feedback
predates the current head SHA), and
'author_pushed_after_request_changes' (new commits landed after a
blocking REQUEST_CHANGES). On a permission block: 'success' False,
'feedback_not_attempted' True, 'reasons', and 'permission_report' —
deliberately distinct from a successful "no reviews yet" result.
"""
reasons = _profile_operation_gate("gitea.read")
if reasons:
return {
"success": False,
"pr_number": pr_number,
"feedback_not_attempted": True,
"reasons": reasons,
"permission_report": _permission_block_report("gitea.read"),
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
pr = api_request("GET", base, auth) or {}
raw_reviews = api_request("GET", f"{base}/reviews", auth) or []
current_head = (pr.get("head") or {}).get("sha")
reveal = _reveal_endpoints()
ordered = sorted(
raw_reviews,
key=lambda rv: ((rv.get("submitted_at") or ""), rv.get("id") or 0),
)
reviews = []
latest_by_reviewer = {}
latest_reviewed_head = None
for rv in ordered:
state = (rv.get("state") or "").upper()
reviewer = (rv.get("user") or {}).get("login", "")
commit_id = rv.get("commit_id")
entry = {
"reviewer": reviewer,
"verdict": state,
"body": _redact(rv.get("body") or ""),
"submitted_at": rv.get("submitted_at"),
"reviewed_head_sha": commit_id,
"dismissed": bool(rv.get("dismissed")),
"stale": bool(rv.get("stale")) or bool(
commit_id and current_head and commit_id != current_head),
}
if reveal:
entry["url"] = rv.get("html_url")
reviews.append(entry)
# COMMENT reviews never advance the reviewed-head marker or the
# per-reviewer verdict — otherwise a drive-by comment on the
# current head would mask the staleness of an older undismissed
# REQUEST_CHANGES.
if state in _VERDICT_STATES and state != "COMMENT":
latest_reviewed_head = commit_id or latest_reviewed_head
if reviewer:
latest_by_reviewer[reviewer] = entry
blocking = [
e for e in latest_by_reviewer.values()
if e["verdict"] == "REQUEST_CHANGES" and not e["dismissed"]
]
approvals = [
e for e in latest_by_reviewer.values()
if e["verdict"] == "APPROVED" and not e["dismissed"]
]
return {
"success": True,
"pr_number": pr_number,
"pr_state": pr.get("state"),
"current_head_sha": current_head,
"reviews": reviews,
"latest_review_state_by_reviewer": {
login: e["verdict"] for login, e in latest_by_reviewer.items()},
"has_blocking_change_requests": bool(blocking),
"approval_visible": bool(approvals),
"latest_reviewed_head_sha": latest_reviewed_head,
"review_feedback_stale": bool(
latest_reviewed_head and current_head
and latest_reviewed_head != current_head),
"author_pushed_after_request_changes": any(
e["reviewed_head_sha"] and current_head
and e["reviewed_head_sha"] != current_head
for e in blocking),
}
def _evaluate_pr_review_submission(
pr_number: int,
action: str,
body: str = "",
expected_head_sha: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
*,
live: bool,
final_review_decision_ready: bool = False,
) -> dict:
"""Shared gate chain for live submit and dry-run review tools."""
verify_preflight_purity(remote)
action = (action or "").strip().lower()
result = {
"requested_action": action,
"performed": False,
"dry_run": not live,
"would_perform": False,
"authenticated_user": None,
"profile_name": get_profile()["profile_name"],
"pr_author": None,
"pr_number": pr_number,
"head_sha": None,
"expected_head_sha": expected_head_sha,
"remote": remote if remote in REMOTES else None,
"reasons": [],
}
reasons = result["reasons"]
if action not in _REVIEW_ACTIONS:
reasons.append(
f"unknown review action '{action}'; expected one of "
f"{sorted(_REVIEW_ACTIONS)}"
)
return result
eligibility_action, event = _REVIEW_ACTIONS[action]
if live:
reasons.extend(check_review_decision_gate(
pr_number,
action,
final_review_decision_ready=final_review_decision_ready,
remote=remote,
org=org,
repo=repo,
))
if reasons:
return result
elig = gitea_check_pr_eligibility(
pr_number=pr_number,
action=eligibility_action,
remote=remote,
host=host,
org=org,
repo=repo,
)
result["authenticated_user"] = elig.get("authenticated_user")
result["profile_name"] = elig.get("profile_name", result["profile_name"])
result["pr_author"] = elig.get("pr_author")
result["head_sha"] = elig.get("head_sha")
if not elig.get("eligible"):
reasons.append(
f"eligibility check for '{eligibility_action}' failed (fail closed)"
)
reasons.extend(elig.get("reasons", []))
if elig.get("permission_report"):
result["permission_report"] = elig["permission_report"]
return result
auth_user = result["authenticated_user"]
pr_author = result["pr_author"]
if action == "approve" and auth_user and pr_author and auth_user == pr_author:
reasons.append("self-approval blocked (authenticated user is PR author)")
return result
actual_sha = result["head_sha"]
pinned_sha = expected_head_sha
lock = _load_review_decision_lock() or {}
if live and lock.get("ready_expected_head_sha"):
pinned_sha = lock.get("ready_expected_head_sha")
if pinned_sha and actual_sha and pinned_sha != actual_sha:
reasons.append(
"expected head SHA does not match current PR head (fail closed)"
)
return result
if not actual_sha:
reasons.append("PR head SHA unavailable (fail closed)")
return result
result["would_perform"] = True
if not live:
reasons.append(
f"dry-run only: would submit '{event}' review on PR #{pr_number}; "
"no live mutation performed"
)
return result
# Gate 5 — in-process mutation authority (#199): the last check before
# the mutating POST, using the identity the eligibility gate proved.
try:
verify_mutation_authority(remote, host, required_role="reviewer",
active_identity=auth_user)
except RuntimeError as e:
reasons.append(str(e))
return result
h, o, r = _resolve(remote, host, org, repo)
review_id = None
submitted_state = None
try:
auth = _auth(h)
review_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews"
payload = {"body": body, "event": event, "commit_id": actual_sha}
resp = api_request("POST", review_url, auth, payload)
if isinstance(resp, dict):
review_id = resp.get("id")
submitted_state = (resp.get("state") or "").upper() or None
if (
submitted_state == "PENDING"
and review_id
and event in _SUBMIT_VISIBLE_EVENTS
):
resp = _submit_pending_pull_review(
h, o, r, pr_number, review_id, event, body, auth,
)
if isinstance(resp, dict):
submitted_state = (resp.get("state") or "").upper() or None
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(f"review submission failed: {_redact(str(exc))}")
return result
if action in _TERMINAL_REVIEW_ACTIONS:
try:
auth = _auth(h)
raw_reviews = (
api_request("GET", f"{review_url}", auth) or []
)
visible = _latest_review_state_for_reviewer(raw_reviews, auth_user)
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(
f"could not verify submitted review verdict (fail closed): "
f"{_redact(str(exc))}"
)
return result
result["submitted_verdict"] = visible
result["review_verdict_visible"] = visible == event
if visible != event:
reasons.append(
f"review submission left verdict '{submitted_state or visible or 'PENDING'}'; "
f"expected visible '{event}' for reviewer '{auth_user}' (fail closed)"
)
return result
record_live_review_mutation(pr_number, action, review_id)
result["performed"] = True
reasons.append(f"all gates passed; submitted '{event}' review on PR #{pr_number}")
return result
@mcp.tool()
def gitea_mark_final_review_decision(
pr_number: int,
action: str,
expected_head_sha: str | None = None,
remote: str = "dadeschools",
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Mark validation complete; the final review decision is ready to submit."""
action = (action or "").strip().lower()
lock = _load_review_decision_lock()
if lock is None:
return {
"marked_ready": False,
"reasons": [
"review decision lock missing; resolve review_pr capability first"
],
}
session_reasons = _review_decision_session_reasons(lock)
if session_reasons:
return {"marked_ready": False, "reasons": session_reasons}
if remote != lock.get("remote"):
return {
"marked_ready": False,
"reasons": [
f"requested remote '{remote}' does not match locked remote "
f"'{lock.get('remote')}' (fail closed)"
],
}
try:
_, resolved_org, resolved_repo = _resolve(remote, None, org, repo)
except ValueError as exc:
return {"marked_ready": False, "reasons": [str(exc)]}
if org is not None and org != resolved_org:
return {
"marked_ready": False,
"reasons": [
f"requested org '{org}' does not match resolved org "
f"'{resolved_org}' (fail closed)"
],
}
if repo is not None and repo != resolved_repo:
return {
"marked_ready": False,
"reasons": [
f"requested repo '{repo}' does not match resolved repo "
f"'{resolved_repo}' (fail closed)"
],
}
org = resolved_org
repo = resolved_repo
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
if hard_stop:
return {"marked_ready": False, "reasons": hard_stop}
if lock.get("live_mutations") and not lock.get("correction_authorized"):
return {
"marked_ready": False,
"reasons": [
"cannot mark final decision after a live review mutation was "
"already recorded in this run unless an operator-approved "
"correction was authorized"
],
}
if action not in _REVIEW_ACTIONS:
return {
"marked_ready": False,
"reasons": [
f"unknown review action '{action}'; expected one of "
f"{sorted(_REVIEW_ACTIONS)}"
],
}
if action == "request_changes":
# Duplicate request-changes suppression (#332): an unresolved
# REQUEST_CHANGES at the current head must not be duplicated.
feedback = gitea_get_pr_review_feedback(
pr_number, remote=remote, org=org, repo=repo)
if not feedback.get("success"):
return {
"marked_ready": False,
"reasons": [
"could not verify existing request-changes state for "
f"PR #{pr_number}; refusing to submit a possibly "
"duplicate REQUEST_CHANGES (fail closed, #332)"
],
}
if (
feedback.get("has_blocking_change_requests")
and not feedback.get("review_feedback_stale")
):
return {
"marked_ready": False,
"reasons": [
f"PR #{pr_number} already has an unresolved "
"REQUEST_CHANGES at the current head SHA; do not "
"duplicate the request-changes review (fail closed, "
"#332)"
],
}
lock["final_review_decision_ready"] = True
lock["ready_pr_number"] = pr_number
lock["ready_action"] = action
lock["ready_expected_head_sha"] = expected_head_sha
lock["ready_remote"] = remote
lock["ready_org"] = org
lock["ready_repo"] = repo
_save_review_decision_lock(lock)
return {
"marked_ready": True,
"pr_number": pr_number,
"action": action,
"expected_head_sha": expected_head_sha,
"remote": remote,
"org": org,
"repo": repo,
"final_review_decision_ready": True,
"reasons": [],
}
@mcp.tool()
def gitea_authorize_review_correction(
prior_review_id: int,
prior_review_state: str,
reason: str,
operator_authorized: bool = False,
) -> dict:
"""Authorize one operator-approved correction after a mistaken live review."""
reason = (reason or "").strip()
prior_review_state = (prior_review_state or "").strip().lower()
lock = _load_review_decision_lock()
if lock is None:
return {"authorized": False, "reasons": ["review decision lock missing"]}
session_reasons = _review_decision_session_reasons(lock)
if session_reasons:
return {"authorized": False, "reasons": session_reasons}
if not operator_authorized:
return {
"authorized": False,
"reasons": [
"operator authorization is required for review correction "
"(fail closed)"
],
}
prior = list(lock.get("live_mutations") or [])
if not prior:
return {
"authorized": False,
"reasons": ["no prior live review mutation to correct"],
}
last_mutation = prior[-1]
last_review_id = last_mutation.get("review_id")
last_review_state = last_mutation.get("action")
reasons = []
if last_review_id is not None and last_review_id != prior_review_id:
reasons.append(
f"prior review ID '{prior_review_id}' does not match last "
f"recorded review ID '{last_review_id}' (fail closed)"
)
if last_review_state != prior_review_state:
reasons.append(
f"prior review state '{prior_review_state}' does not match last "
f"recorded review state '{last_review_state}' (fail closed)"
)
if not reason:
reasons.append("correction reason is required")
if reasons:
return {"authorized": False, "reasons": reasons}
lock["correction_authorized"] = True
lock["correction_reason"] = reason
_save_review_decision_lock(lock)
return {"authorized": True, "correction_reason": reason, "reasons": []}
@mcp.tool()
def gitea_dry_run_pr_review(
pr_number: int,
action: str,
body: str = "",
expected_head_sha: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Validate review submission mechanics without a live PR mutation."""
return _evaluate_pr_review_submission(
pr_number=pr_number,
action=action,
body=body,
expected_head_sha=expected_head_sha,
remote=remote,
host=host,
org=org,
repo=repo,
live=False,
)
@mcp.tool()
@_audit_pr_result("submit_pr_review")
def gitea_submit_pr_review(
pr_number: int,
action: str,
body: str = "",
expected_head_sha: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
final_review_decision_ready: bool = False,
) -> dict:
"""Gated PR review mutation: comment findings, request changes, or approve.
Live mutations require ``final_review_decision_ready=True`` and a prior
``gitea_mark_final_review_decision`` call. Use ``gitea_dry_run_pr_review``
during validation instead of probing with live submissions.
"""
return _evaluate_pr_review_submission(
pr_number=pr_number,
action=action,
body=body,
expected_head_sha=expected_head_sha,
remote=remote,
host=host,
org=org,
repo=repo,
live=True,
final_review_decision_ready=final_review_decision_ready,
)
@mcp.tool()
def gitea_edit_pr(
pr_number: int,
title: str | None = None,
body: str | None = None,
state: str | None = None,
base: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Edit an existing pull request on a Gitea repository.
Closing a PR (``state='closed'``) is a distinct capability from other
edits (#216): it requires the ``gitea.pr.close`` operation (resolver
task ``close_pr``), fails closed with a structured permission report
when the active profile lacks it, and is audited as a distinct
``close_pr`` action. Title/body/base edits and reopening stay on the
ordinary edit path, so the edit tool can never be used as an untracked
close fallback.
Args:
pr_number: The pull request index/number (required).
title: New PR title.
body: New PR description.
state: New state — 'open' or 'closed'. 'closed' requires the
``gitea.pr.close`` capability.
base: Target branch name.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with success status and details of the edited PR. A close
attempt without ``gitea.pr.close`` returns 'success'/'performed'
False with 'reasons' and a structured 'permission_report' and makes
no API call.
"""
# Validate inputs BEFORE any auth/profile resolution or API setup: a
# no-fields call is a pure validation error and must not depend on
# credentials, network, or environment configuration.
payload = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if state is not None:
if state not in ("open", "closed"):
raise ValueError(
f"Invalid state {state!r}: must be 'open' or 'closed' (fail closed).")
payload["state"] = state
if base is not None:
payload["base"] = base
if not payload:
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
verify_preflight_purity(remote)
# PR closure is a first-class capability, distinct from retitling or
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
# never touches the network.
closing = payload.get("state") == "closed"
if closing:
gate_reasons = _profile_operation_gate("gitea.pr.close")
if gate_reasons:
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"requested_state": "closed",
"required_permission": "gitea.pr.close",
"reasons": gate_reasons,
"permission_report": _permission_block_report("gitea.pr.close"),
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
request_metadata = {"fields": sorted(payload)}
if closing:
request_metadata["required_permission"] = "gitea.pr.close"
with _audited("close_pr" if closing else "edit_pr",
host=h, remote=remote, org=o, repo=r,
pr_number=pr_number, request_metadata=request_metadata):
data = api_request("PATCH", url, auth, payload)
cleanup_status = None
if state == "closed":
cleanup = cleanup_in_progress_for_pr(data, remote, host, org, repo)
cleanup_status = cleanup.get("cleanup_status")
if isinstance(cleanup_status, dict):
for issue_num, st in cleanup_status.items():
if st == "released":
try:
comment_url = f"{repo_api_url(h, o, r)}/issues/{issue_num}/comments"
api_request("POST", comment_url, auth, {"body": f"Tracker cleanup: removed `status:in-progress` from this issue because linked PR #{pr_number} was closed."})
except Exception:
pass
result = {
"success": True,
"number": data["number"],
"title": data["title"],
"body": data.get("body", ""),
"state": data["state"],
"cleanup_status": cleanup_status,
}
return _with_optional_url(result, data.get("html_url"))
@mcp.tool()
def gitea_get_file(
filepath: str,
ref: str = "main",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Retrieve metadata and content of a file from a Gitea repository.
Args:
filepath: The path to the file in the repository (e.g. 'README.md' or 'src/main.py').
ref: The branch, tag, or commit hash to retrieve the file from (default: 'main').
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict containing 'name', 'path', 'sha', 'size', 'encoding', and 'content' (base64).
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
import urllib.parse
encoded_path = urllib.parse.quote(filepath, safe="")
url = f"{repo_api_url(h, o, r)}/contents/{encoded_path}?ref={ref}"
data = api_request("GET", url, auth)
return {
"name": data.get("name", ""),
"path": data.get("path", ""),
"sha": data.get("sha", ""),
"size": data.get("size", 0),
"encoding": data.get("encoding", ""),
"content": data.get("content", ""),
}
def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[dict]]:
import base64
import json
import os
processed_files = []
source_proofs = []
lock_data = {}
if os.path.exists(ISSUE_LOCK_FILE):
try:
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
except Exception:
pass
locked_worktree = lock_data.get("worktree_path")
if locked_worktree:
locked_worktree = os.path.realpath(locked_worktree)
for f in files:
f_copy = dict(f)
path = f_copy.get("path", "")
content_keys = [k for k in ["content", "content_plain", "workspace_path", "local_path"] if k in f_copy]
if len(content_keys) > 1:
raise ValueError(
f"Multiple content sources specified for file '{path}'; provide exactly one of "
f"'content', 'content_plain', 'workspace_path', or 'local_path' (fail closed)"
)
source = "none"
if "content_plain" in f_copy:
content_plain = f_copy.pop("content_plain")
if content_plain is not None:
content_bytes = content_plain.encode("utf-8")
f_copy["content"] = base64.b64encode(content_bytes).decode("utf-8")
source = "inline_plain"
elif "workspace_path" in f_copy:
workspace_path = f_copy.pop("workspace_path")
if not locked_worktree:
raise RuntimeError(
f"Issue lock is missing or does not define a worktree path. "
f"Cannot resolve workspace_path '{workspace_path}' (fail closed)."
)
target_path = os.path.realpath(os.path.abspath(os.path.join(locked_worktree, workspace_path)))
is_inside = target_path == locked_worktree or target_path.startswith(os.path.join(locked_worktree, ""))
is_tmp = target_path.startswith(os.path.realpath("/tmp") + os.sep) or target_path.startswith("/tmp" + os.sep)
if not (is_inside or is_tmp):
raise ValueError(
f"workspace_path '{workspace_path}' resolves to '{target_path}' which falls outside "
f"of locked worktree '{locked_worktree}' (fail closed)"
)
if not os.path.exists(target_path):
raise FileNotFoundError(f"File not found: {target_path} (fail closed)")
with open(target_path, "rb") as fh:
file_bytes = fh.read()
f_copy["content"] = base64.b64encode(file_bytes).decode("utf-8")
source = "workspace_path"
elif "local_path" in f_copy:
local_path = f_copy.pop("local_path")
if not locked_worktree:
raise RuntimeError(
f"Issue lock is missing or does not define a worktree path. "
f"Cannot resolve local_path '{local_path}' (fail closed)."
)
target_path = os.path.realpath(os.path.abspath(local_path))
is_inside = target_path == locked_worktree or target_path.startswith(os.path.join(locked_worktree, ""))
is_tmp = target_path.startswith(os.path.realpath("/tmp") + os.sep) or target_path.startswith("/tmp" + os.sep)
if not (is_inside or is_tmp):
raise ValueError(
f"local_path '{local_path}' resolves to '{target_path}' which falls outside "
f"of locked worktree '{locked_worktree}' (fail closed)"
)
if not os.path.exists(target_path):
raise FileNotFoundError(f"File not found: {target_path} (fail closed)")
with open(target_path, "rb") as fh:
file_bytes = fh.read()
f_copy["content"] = base64.b64encode(file_bytes).decode("utf-8")
source = "local_path"
elif "content" in f_copy:
source = "inline_base64"
processed_files.append(f_copy)
source_proofs.append({
"path": path,
"source": source
})
return processed_files, source_proofs
@mcp.tool()
def gitea_commit_files(
files: list[dict],
message: str,
branch: str | None = None,
new_branch: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Commit changes to multiple files in a Gitea repository in a single atomic commit.
Args:
files: List of file operations. Each file dict must contain 'operation' ('create', 'update', 'delete', 'rename'), 'path', and one of content payload sources: 'content' (base64), 'content_plain', 'workspace_path', or 'local_path'.
message: The commit message.
branch: Optional existing branch to start/commit from.
new_branch: Optional new branch name to create for this commit.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with success status and commit/branch information.
"""
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
"commit_files"
)
if not ok:
return {
"success": False,
"performed": False,
"commit": "",
"branch": "",
"reasons": block_reasons,
}
blocked = _namespace_mutation_block(
"commit_files", commit="", branch="", remote=remote
)
if blocked:
return blocked
blocked = _profile_permission_block(
task_capability_map.required_permission("commit_files"),
commit="", branch="",
)
if blocked:
return blocked
verify_preflight_purity(remote)
processed_files, source_proofs = _prepare_commit_payload_files(files)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/contents"
payload = {
"files": processed_files,
"message": message,
}
if branch is not None:
payload["branch"] = branch
if new_branch is not None:
payload["new_branch"] = new_branch
with _audited("commit_files", host=h, remote=remote, org=o, repo=r,
target_branch=(new_branch or branch),
request_metadata={"message": message,
"paths": [f.get("path") for f in processed_files],
"operations": [f.get("operation") for f in processed_files]}):
data = api_request("POST", url, auth, payload)
return {
"success": True,
"commit": data.get("commit", {}).get("sha", ""),
"branch": data.get("branch", {}).get("name", ""),
"content_source_proof": source_proofs,
}
# Merge methods supported by the Gitea merge API.
_MERGE_METHODS = ("merge", "squash", "rebase")
@mcp.tool()
@_audit_pr_result("merge_pr")
def gitea_merge_pr(
pr_number: int,
confirmation: str = "",
expected_head_sha: str | None = None,
expected_changed_files: list[str] | None = None,
do: str = "merge",
title: str | None = None,
message: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Gated merge of a Gitea pull request (#16).
This is the ONLY merge path this server exposes, and it mutates only after
every safety gate passes. No ungated merge tool remains: legacy
``gitea_review_pr`` fails closed on ``merge=True`` and
``gitea_submit_pr_review`` never merges.
Gate order (fail-closed at each step; the merge API is called only if all
gates pass):
1. Merge method (``do``) is 'merge', 'squash', or 'rebase'.
2. Explicit confirmation: ``confirmation`` must equal ``"MERGE PR <n>"``.
Without it, the tool makes no API calls at all.
3. Reuse ``gitea_check_pr_eligibility`` (#14) with action 'merge': this
proves the authenticated identity, the active profile (and that it
allows merge), the PR author, blocks self-merge, requires the PR to be
open, and fails closed when the PR is not mergeable or mergeability is
unknown.
4. If ``expected_head_sha`` is given and the PR head moved → refuse.
5. If ``expected_changed_files`` is given and the PR's changed file set
differs → refuse.
6. Redundant self-merge block (authenticated user == PR author).
7. Re-read formal review feedback (#167): refuse when
``approval_visible`` is false or undismissed REQUEST_CHANGES block
merge — PENDING draft approvals do not count (#244).
No force / ignore-checks option is exposed. Gitea's own ``mergeable`` signal
(which reflects branch-protection required reviews and status checks) must
be positive, so required approval/check state is honoured, never bypassed.
Never returns the token, Authorization header, or any credential material.
Args:
pr_number: The PR number to merge.
confirmation: Must be exactly ``"MERGE PR <pr_number>"`` or merge is refused.
expected_head_sha: Strongly recommended. If set and the PR head differs, refuse.
expected_changed_files: Optional. If set and the PR's changed file set
differs, refuse.
do: Merge style — 'merge', 'squash', or 'rebase'.
title: Optional merge commit title.
message: Optional merge commit message.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict describing the attempt: performed, authenticated user, profile
name, PR author, PR number, head SHA checked, merge method,
reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets.
"""
verify_preflight_purity(remote)
do = (do or "").strip().lower()
result = {
"performed": False,
"authenticated_user": None,
"profile_name": get_profile()["profile_name"],
"pr_author": None,
"pr_number": pr_number,
"head_sha": None,
"expected_head_sha": expected_head_sha,
"merge_method": do,
"mergeable": None,
"remote": remote if remote in REMOTES else None,
"merge_result": None,
"merge_commit": None,
"reasons": [],
}
reasons = result["reasons"]
# Gate 1 — valid merge method (no API call on a bad method).
if do not in _MERGE_METHODS:
reasons.append(
f"unknown merge method '{do}'; expected one of {list(_MERGE_METHODS)}"
)
return result
# Gate 2 — explicit confirmation (fail fast; zero API calls without it).
expected_confirmation = f"MERGE PR {pr_number}"
if (confirmation or "").strip() != expected_confirmation:
reasons.append(
f"explicit confirmation required: pass confirmation='{expected_confirmation}'"
)
return result
# Gate 2b — session hard-stop after a terminal review mutation (#332):
# merge may only continue for the same PR that was just approved.
# Local in-process check; zero API calls.
hard_stop = terminal_review_hard_stop_reasons(pr_number, "merge")
if hard_stop:
reasons.extend(hard_stop)
return result
# Gate 3 — reuse #14 eligibility (identity + profile + merge-allowed +
# author + self-merge block + open + mergeable/unknown fail-closed).
# Read-only GETs only.
elig = gitea_check_pr_eligibility(
pr_number=pr_number, action="merge", remote=remote,
host=host, org=org, repo=repo,
)
result["authenticated_user"] = elig.get("authenticated_user")
result["profile_name"] = elig.get("profile_name", result["profile_name"])
result["pr_author"] = elig.get("pr_author")
result["head_sha"] = elig.get("head_sha")
result["mergeable"] = elig.get("mergeable")
if not elig.get("eligible"):
reasons.append("eligibility check for 'merge' failed (fail closed)")
reasons.extend(elig.get("reasons", []))
if elig.get("permission_report"):
result["permission_report"] = elig["permission_report"]
return result
# Gate 4 — head SHA must match if the caller pinned a reviewed SHA.
actual_sha = result["head_sha"]
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
reasons.append(
"expected head SHA does not match current PR head (fail closed)"
)
return result
if not actual_sha:
# Unreachable — eligibility fails closed without a head SHA — but never
# merge a PR whose head commit we could not read.
reasons.append("PR head SHA unavailable (fail closed)")
return result
h, o, r = _resolve(remote, host, org, repo)
# Gate 5 — changed files must match the reviewed set, if provided.
if expected_changed_files is not None:
try:
auth = _auth(h)
files = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}/files", auth
)
actual_files = sorted(
(f or {}).get("filename", "") for f in (files or [])
)
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(
f"could not verify changed files (fail closed): {_redact(str(exc))}"
)
return result
result["changed_files"] = actual_files
if actual_files != sorted(expected_changed_files):
reasons.append(
"PR changed files do not match expected_changed_files (fail closed)"
)
return result
# Gate 6 — redundant self-merge block (belt-and-suspenders over #14).
auth_user = result["authenticated_user"]
pr_author = result["pr_author"]
if auth_user and pr_author and auth_user == pr_author:
reasons.append("self-merge blocked (authenticated user is PR author)")
return result
# Gate 7 — visible formal approval required (#244). PENDING drafts and
# absent verdicts do not satisfy this gate even when Gitea mergeable is true.
feedback = gitea_get_pr_review_feedback(
pr_number=pr_number, remote=remote, host=host, org=org, repo=repo,
)
if not feedback.get("success"):
reasons.append("PR review feedback unavailable before merge (fail closed)")
reasons.extend(feedback.get("reasons", []))
if feedback.get("permission_report"):
result["permission_report"] = feedback["permission_report"]
return result
result["approval_visible"] = feedback.get("approval_visible")
result["has_blocking_change_requests"] = feedback.get(
"has_blocking_change_requests")
if feedback.get("has_blocking_change_requests"):
reasons.append(
"undismissed REQUEST_CHANGES review blocks merge (fail closed)"
)
return result
if not feedback.get("approval_visible"):
reasons.append(
"no visible APPROVED review on PR; verify review submission "
"completed before merge (fail closed)"
)
return result
# Gate 8 — in-process mutation authority (#199): the last check before
# the merge mutation, using the identity the eligibility gate proved.
# A profile/identity flip or side-channel override between preflight
# and merge fails closed here.
try:
verify_mutation_authority(remote, host, required_role="reviewer",
active_identity=auth_user)
except RuntimeError as e:
reasons.append(str(e))
return result
# All gates passed — perform the single merge mutation.
try:
auth = _auth(h)
merge_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/merge"
payload = {"Do": do}
if title:
payload["MergeTitleField"] = title
if message:
payload["MergeMessageField"] = message
api_request("POST", merge_url, auth, payload)
# Best-effort read-back of the merge commit SHA (redacted on error).
merged = None
try:
merged = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
)
result["merge_commit"] = (merged or {}).get("merged_commit_sha")
except Exception:
result["merge_commit"] = None
# Tracker cleanup (#98): never blocks the merge, and always surfaces an
# explicit cleanup_status once the merge mutation has happened. Cleanup
# needs the PR title/body/branch, which only the read-back payload
# carries here — so a failed read-back is an explicit skip, not a
# silent one.
if merged is None:
result["cleanup_status"] = "skipped (merge read-back failed)"
else:
try:
cleanup = cleanup_in_progress_for_pr(merged, remote, host, org, repo)
result["cleanup_status"] = cleanup.get("cleanup_status")
except Exception as cleanup_exc: # noqa: BLE001 — redact before surfacing
result["cleanup_status"] = (
f"skipped (cleanup error: {_redact(str(cleanup_exc))})"
)
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(f"merge failed: {_redact(str(exc))}")
return result
result["performed"] = True
result["merge_result"] = f"PR #{pr_number} merged via '{do}'."
reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'")
return result
def _local_git_remote_url(remote_name: str) -> str | None:
"""Best-effort local ``git remote get-url`` for trust-gate corroboration."""
try:
proc = subprocess.run(
["git", "remote", "get-url", remote_name],
capture_output=True,
text=True,
cwd=PROJECT_ROOT,
)
if proc.returncode != 0:
return None
url = (proc.stdout or "").strip()
return url or None
except Exception:
return None
@mcp.tool()
def gitea_review_pr(
pr_number: int,
event: str = "APPROVE",
body: str = "",
merge: bool = False,
merge_method: str = "merge",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
final_review_decision_ready: bool = False,
) -> dict:
"""Submit a review on a Gitea pull request (Legacy wrapper).
This tool is a compatibility wrapper around the safe `gitea_submit_pr_review`.
It uses the same #14 eligibility gates.
Merging via this tool is no longer supported and will fail closed (see #16).
Args:
pr_number: The PR number to review.
event: Review type — 'APPROVE', 'COMMENT', or 'REQUEST_CHANGES'.
body: Review body text / comment.
merge: Merging is disabled; if True, the tool fails closed.
merge_method: Ignored.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with success status and message.
"""
if merge:
return {
"success": False,
"message": "merge=True is no longer supported in this tool (belongs to #16)."
}
if event not in ["APPROVE", "COMMENT", "REQUEST_CHANGES"]:
raise ValueError(f"Invalid review event: '{event}'. Choose from 'APPROVE', 'COMMENT', 'REQUEST_CHANGES'.")
# Map legacy event string to the action expected by gitea_submit_pr_review
event_map = {
"APPROVE": "approve",
"COMMENT": "comment",
"REQUEST_CHANGES": "request_changes"
}
action = event_map[event]
# --- Phase 1: Read-only PR queue inventory ---
profile = get_profile()
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
switching_supported = gitea_config.is_runtime_switching_enabled()
can_review, _ = gitea_config.check_operation("gitea.pr.review", allowed, forbidden)
can_approve, _ = gitea_config.check_operation("gitea.pr.approve", allowed, forbidden)
can_merge, _ = gitea_config.check_operation("gitea.pr.merge", allowed, forbidden)
can_read, _ = gitea_config.check_operation("gitea.read", allowed, forbidden)
is_reviewer = can_review or can_approve or can_merge
inventory_attempted = False
prs_found_count = 0
pr_details_list = []
inventory_msg = ""
inventory_trust_gate = None
prs: list = []
h, o, r = _resolve(remote, host, org, repo)
auth = None
try:
auth = _auth(h)
except Exception:
pass
auth_user = None
if auth:
try:
who = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
auth_user = (who or {}).get("login")
except Exception:
pass
if can_read and auth:
inventory_attempted = True
try:
prs = gitea_list_prs(
state="open",
remote=remote,
host=h,
org=o,
repo=r,
)
prs_list = prs.get("prs") or []
prs_found_count = len(prs_list)
for pr in prs_list:
labels = list(pr.get("labels") or [])
body_text = pr.get("body") or ""
linked = []
# Simple extraction of linked issues from body
for match in re.findall(r'#(\d+)', body_text):
linked.append(f"#{match}")
pr_head_sha = pr.get("head_sha")
pr_author = pr.get("author")
# Determine review block status
req_non_author = False
if pr_author and auth_user and pr_author == auth_user:
req_non_author = True
pr_details_list.append({
"number": pr["number"],
"title": pr["title"],
"author": pr_author,
"base": pr["base"],
"head": pr["head"],
"mergeable": pr.get("mergeable"),
"linked_issues": list(set(linked)),
"labels": labels,
"head_sha": pr_head_sha,
"updated_at": pr.get("updated_at"),
"requires_non_author_reviewer": req_non_author
})
except Exception as e:
inventory_msg = f"PR inventory failed: {_redact(str(e))}"
else:
inventory_msg = "PR inventory not attempted"
# Build inventory report string
report_lines = []
if inventory_attempted:
report_lines.append(f"Repository: {o}/{r}")
if pr_details_list:
report_lines.append(f"Open PRs found: {prs_found_count}")
for item in pr_details_list:
status_str = "Review/merge blocked (Current profile is Author)"
if item["requires_non_author_reviewer"]:
status_str = "Review/merge blocked (Self-author). Requires a genuine non-author reviewer"
elif is_reviewer:
status_str = "Review/merge eligible under current profile"
report_lines.append(
f"- PR #{item['number']}: {item['title']}\n"
f" Author: {item['author']}\n"
f" Base/Head: {item['base']} / {item['head']}\n"
f" Head SHA: {item['head_sha']}\n"
f" Updated: {item.get('updated_at') or 'unknown'}\n"
f" Mergeability: {item['mergeable']}\n"
f" Linked Issues: {item['linked_issues']}\n"
f" Labels: {item['labels']}\n"
f" Status: {status_str}"
)
else:
if inventory_msg:
report_lines.append(inventory_msg)
else:
pagination = (
prs.get("pagination") if isinstance(prs, dict) else {}
) or {}
has_finality_metadata = bool(
pagination.get("inventory_complete")
or (
pagination.get("is_final_page")
and not pagination.get("has_more")
)
)
inventory_trust_gate = review_proofs.pr_inventory_trust_gate(
prs if inventory_attempted else None,
remote=remote,
org=o,
repo=r,
state="open",
authenticated_profile=profile,
local_remote_url=_local_git_remote_url(remote),
has_finality_metadata=has_finality_metadata,
)
report_lines.extend(
review_proofs.format_pr_inventory_trust_gate_report(
inventory_trust_gate
)
)
if inventory_trust_gate.get("status") == "trusted_empty":
report_lines.append("Open PRs found: 0 (trusted_empty)")
else:
report_lines.append(
"Empty-queue claim blocked: "
"pr_inventory_trust_gate did not return trusted_empty"
)
else:
report_lines.append(inventory_msg)
inventory_report = "\n".join(report_lines)
# If the current profile is not a reviewer, or lacks necessary permissions for review action
if not is_reviewer:
# Determine the safe next step based on static vs dynamic profile switching support
if switching_supported:
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
else:
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
blocked_message = (
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
f"=== Review/Merge Blocked ===\n"
f"The active profile '{profile['profile_name']}' is an Author profile and does not have review or merge permissions.\n"
f"Safe next step: {safe_step}"
)
return {
"success": False,
"message": blocked_message
}
# --- Phase 2: Reviewer-only review/merge actions ---
# Since it is a reviewer profile, we proceed to submit the review
result = gitea_submit_pr_review(
pr_number=pr_number,
action=action,
body=body,
expected_head_sha=None,
remote=remote,
host=host,
org=org,
repo=repo,
final_review_decision_ready=final_review_decision_ready
)
# Include the inventory report in the response message
if result.get("performed"):
msg = (
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
f"Successfully submitted review for PR #{pr_number} with event '{event}'."
)
return {"success": True, "message": msg}
else:
reasons = result.get("reasons", [])
msg = (
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
f"Review submission failed eligibility gates: {reasons}"
)
out = {"success": False, "message": msg}
if result.get("permission_report"):
out["permission_report"] = result["permission_report"]
return out
@mcp.tool()
def gitea_delete_branch(
branch: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Delete a remote branch from a Gitea repository.
Args:
branch: The remote branch name (e.g. 'feat/branch-name').
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success' and 'message'.
"""
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
import urllib.parse
encoded_branch = urllib.parse.quote(branch, safe="")
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
target_branch=branch, request_metadata={"branch": branch}):
api_request("DELETE", url, auth)
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
import urllib.parse
encoded = urllib.parse.quote(branch, safe="")
url = f"{repo_api_url(h, o, r)}/branches/{encoded}"
try:
api_request("GET", url, auth)
return True
except Exception as exc:
message = str(exc).lower()
if "404" in message or "not found" in message:
return False
raise
@mcp.tool()
def gitea_reconcile_merged_cleanups(
dry_run: bool = True,
execute_confirmed: bool = False,
limit: int = 50,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Reconcile merged PRs by reporting and optionally cleaning remote branches and local worktrees.
Args:
dry_run: Defaults to True. When True, only builds the reconciliation report.
execute_confirmed: Must be True when dry_run=False.
limit: Max number of closed PRs to inspect.
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with reconciliation report and any executed cleanup actions.
"""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"performed": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
if not dry_run and not execute_confirmed:
raise ValueError(
"execute_confirmed must be True when dry_run=False (fail closed)"
)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
merged_closed: list[dict] = []
remote_branch_exists: dict[str, bool] = {}
head_on_master: dict[int, bool | None] = {}
master_ref = f"{remote}/master" if remote in REMOTES else "origin/master"
for pr in closed_prs:
if not (pr.get("merged") or pr.get("merged_at")):
continue
merged_closed.append(pr)
head_ref = (pr.get("head") or {}).get("ref") or ""
head_sha = (pr.get("head") or {}).get("sha")
if head_ref and head_ref not in remote_branch_exists:
remote_branch_exists[head_ref] = _remote_branch_exists(
h, o, r, auth, head_ref
)
head_on_master[int(pr["number"])] = merged_cleanup_reconcile.is_head_ancestor_of_ref(
PROJECT_ROOT, head_sha, master_ref
)
delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete")
report = merged_cleanup_reconcile.build_reconciliation_report(
project_root=PROJECT_ROOT,
closed_prs=merged_closed,
open_prs=open_prs,
remote_branch_exists=remote_branch_exists,
head_on_master=head_on_master,
delete_capability_allowed=delete_capability_allowed,
)
if dry_run:
report["dry_run"] = True
report["executed"] = False
return {"success": True, "performed": False, **report}
verify_preflight_purity(remote)
actions: list[dict] = []
for entry in report.get("entries") or []:
head_branch = entry.get("head_branch") or ""
remote_assessment = entry.get("remote_branch") or {}
local_assessment = entry.get("local_worktree") or {}
if remote_assessment.get("safe_to_delete_remote"):
import urllib.parse
encoded = urllib.parse.quote(head_branch, safe="")
url = f"{base}/branches/{encoded}"
with _audited(
"delete_branch",
host=h,
remote=remote,
org=o,
repo=r,
target_branch=head_branch,
request_metadata={"branch": head_branch, "source": "reconcile_merged_cleanups"},
):
api_request("DELETE", url, auth)
actions.append(
{
"action": "delete_remote_branch",
"branch": head_branch,
"success": True,
}
)
if local_assessment.get("safe_to_remove_worktree"):
result = merged_cleanup_reconcile.remove_local_worktree(
PROJECT_ROOT, head_branch
)
actions.append({"action": "remove_local_worktree", **result})
report["dry_run"] = False
report["executed"] = True
report["actions"] = actions
return {"success": True, "performed": True, **report}
@mcp.tool()
def gitea_close_issue(
issue_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Close an issue by setting its state to 'closed'.
Args:
issue_number: The issue number to close.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success' boolean and 'message'.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("close_issue"))
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number, request_metadata={"state": "closed"}):
api_request("PATCH", url, auth, {"state": "closed"})
cleanup_result = release_in_progress_label([issue_number], remote, host, org, repo)
return {
"success": True,
"message": f"Issue #{issue_number} closed.",
"cleanup_status": cleanup_result
}
@mcp.tool()
def gitea_list_issues(
state: str = "open",
label: str | None = None,
limit: int = 50,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list[dict]:
"""List issues on a Gitea repository with optional filters.
Args:
state: Filter by state — 'open', 'closed', or 'all'.
label: Filter by label name (e.g. 'important').
limit: Max number of issues to return across all pages (default: 50).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
List of dicts with 'number', 'title', 'state', 'labels', 'assignee'.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
params = f"state={state}&type=issues"
if label:
params += f"&labels={label}"
url = f"{repo_api_url(h, o, r)}/issues?{params}"
issues = api_get_all(url, auth, limit=limit)
return [
{
"number": i["number"],
"title": i["title"],
"state": i["state"],
"labels": [lb["name"] for lb in i.get("labels", [])],
"assignee": (i.get("assignee") or {}).get("login", ""),
}
for i in issues
]
@mcp.tool()
def gitea_view_issue(
issue_number: int,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Get full details of a single issue.
Args:
issue_number: The issue number to view.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'number', 'title', 'body', 'state', 'labels', and 'assignee'
('url' only with the reveal opt-in).
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
i = api_request("GET", url, auth)
result = {
"number": i["number"],
"title": i["title"],
"body": i.get("body", ""),
"state": i["state"],
"labels": [lb["name"] for lb in i.get("labels", [])],
"assignee": (i.get("assignee") or {}).get("login", ""),
}
return _with_optional_url(result, i.get("html_url"))
def _permission_block_report(required_operation: str,
identity: str | None = None) -> dict:
"""Structured, LLM-safe explanation of a permission denial (#142).
Built only after a gate has already refused; it adds guidance to the
refusal and never widens any permission, performs network I/O, or
raises (fail-soft: degrades to a minimal fail-closed report). Names
configured profiles only — never auth references, tokens, endpoint
URLs, or keychain IDs.
"""
report = {
"requested_operation": required_operation,
"missing_permission": required_operation,
"required_permission": required_operation,
"active_profile": None,
"active_identity": identity,
"active_allowed_operations": [],
"matching_configured_profiles": [],
"runtime_switching_supported": False,
"different_mcp_namespace_required": True,
"exact_safe_next_action": (
"Ask the operator to fix GITEA_MCP_CONFIG/GITEA_MCP_PROFILE; "
"the active profile could not be resolved (fail closed)."),
}
try:
profile = get_profile()
except Exception:
return report
report["active_profile"] = profile.get("profile_name")
if identity is None:
report["active_identity"] = profile.get("identity")
report["active_allowed_operations"] = (
profile.get("allowed_operations") or [])
matching = []
try:
config = gitea_config.load_config() or {}
for name, p in (config.get("profiles") or {}).items():
p_allowed = []
for op in p.get("allowed_operations") or []:
try:
p_allowed.append(gitea_config.normalize_operation(op))
except Exception:
pass
p_forbidden = []
for op in p.get("forbidden_operations") or []:
try:
p_forbidden.append(gitea_config.normalize_operation(op))
except Exception:
pass
ok, _ = gitea_config.check_operation(
required_operation, p_allowed, p_forbidden)
if ok:
matching.append(name)
except Exception:
matching = []
report["matching_configured_profiles"] = matching
try:
switching = gitea_config.is_runtime_switching_enabled()
except Exception:
switching = False
report["runtime_switching_supported"] = switching
role = "reviewer" if required_operation in (
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes",
"gitea.pr.review") else "author"
if switching and matching:
report["different_mcp_namespace_required"] = False
report["exact_safe_next_action"] = (
f"Call gitea_activate_profile with a profile that allows "
f"{required_operation} (matching configured profiles: "
f"{matching}).")
else:
report["different_mcp_namespace_required"] = True
report["exact_safe_next_action"] = (
f"Switch to the {role} MCP session (e.g. gitea-{role}), or ask "
f"the operator to set GITEA_MCP_PROFILE to a profile that "
f"allows {required_operation}.")
return report
def _role_for_operation(op: str) -> str | None:
# Normalize op first
try:
op_n = gitea_config.normalize_operation(op)
except Exception:
op_n = op
if op_n in ("gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review", "gitea.pr.request_changes"):
return "reviewer"
if op_n in (
"gitea.issue.create",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.branch.create",
"gitea.branch.push",
"gitea.pr.create",
"gitea.pr.comment",
"gitea.pr.close",
"gitea.branch.delete",
"gitea.repo.commit"
):
return "author"
return None
def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
"""Try to find a profile in config that allows op and has valid credentials.
If found, switch to it, clear identity cache, and return True.
Otherwise return False.
"""
role = _role_for_operation(op)
if not role:
return False
if not gitea_config.is_runtime_switching_enabled():
return False
config = gitea_config.load_config()
if not config or "profiles" not in config:
return False
for p_name, p_data in config["profiles"].items():
# Role classification matching
p_role = p_data.get("role") or _role_kind(p_data.get("allowed_operations", []), p_data.get("forbidden_operations", []))
if p_role != role:
continue
p_allowed = p_data.get("allowed_operations") or []
p_forbidden = p_data.get("forbidden_operations") or []
p_allowed_n = []
for op_val in p_allowed:
try:
p_allowed_n.append(gitea_config.normalize_operation(op_val))
except Exception:
pass
p_forbidden_n = []
for op_val in p_forbidden:
try:
p_forbidden_n.append(gitea_config.normalize_operation(op_val))
except Exception:
pass
ok, _ = gitea_config.check_operation(op, p_allowed_n, p_forbidden_n)
if ok:
try:
tok = gitea_config.resolve_token(p_data)
if tok:
gitea_config._active_profile_override = p_name
_IDENTITY_CACHE.clear()
return True
except Exception:
pass
return False
def _profile_operation_gate(op: str) -> list[str]:
"""Profile permission check for a single gated operation (#126, #216).
Issue discussion comments are gated separately from the gitea.pr.*
review/merge family: listing requires ``gitea.read``, creating requires
``gitea.issue.comment``. Closing a PR requires the distinct
``gitea.pr.close`` (#216). Returns a list of block reasons (empty =
allowed); an unreadable profile fails closed.
"""
try:
profile = get_profile()
except Exception as exc:
return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
op_ok, op_reason = gitea_config.check_operation(
op, profile["allowed_operations"], profile["forbidden_operations"])
if op_ok:
return []
if _try_auto_switch_for_operation(op):
try:
profile = get_profile()
op_ok, op_reason = gitea_config.check_operation(
op, profile["allowed_operations"], profile["forbidden_operations"])
if op_ok:
return []
except Exception as exc:
return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
if op_reason == "no-allowed-operations":
return ["profile has no configured allowed operations (fail closed)"]
if op_reason == "forbidden":
return [f"profile forbids '{op}'"]
if op_reason == "invalid-forbidden-entry":
return ["profile has an unrecognized forbidden operation entry (fail closed)"]
return [f"profile is not allowed to {op}"]
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
"""Structured permission denial for gated tools (#69, #142).
Returns a block dict when the active profile forbids *required_operation*,
or ``None`` when the gate passes. Never performs network I/O.
"""
req_role = "reviewer" if any(required_operation.startswith(p) for p in (
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes", "gitea.pr.review"
)) else "author"
_ensure_matching_profile(required_operation, req_role, extra_fields.get("remote"))
reasons = _profile_operation_gate(required_operation)
if not reasons:
return None
blocked = {
"success": False,
"performed": False,
"reasons": reasons,
"permission_report": _permission_block_report(required_operation),
}
blocked.update(extra_fields)
return blocked
def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None:
"""Reviewer/author namespace alignment gate (#209)."""
required_permission = task_capability_map.required_permission(mutation_task)
required_role = task_capability_map.required_role(mutation_task)
_ensure_matching_profile(required_permission, required_role, extra_fields.get("remote"))
try:
profile = get_profile()
except Exception as exc:
return {
"success": False,
"performed": False,
"reasons": [
f"profile could not be resolved (fail closed): {_redact(str(exc))}"],
**extra_fields,
}
ok, reasons = role_namespace_gate.check_author_mutation_namespace(
mutation_task, profile)
if ok:
return None
blocked = {
"success": False,
"performed": False,
"reasons": reasons,
"namespace_block": True,
"mcp_namespace": role_namespace_gate.infer_mcp_namespace(
profile.get("profile_name")),
**extra_fields,
}
_audit(
mutation_task,
host=None,
remote=extra_fields.get("remote"),
result=gitea_audit.BLOCKED,
repo=extra_fields.get("repository"),
reason="; ".join(reasons),
mutation_task=mutation_task,
)
return blocked
@mcp.tool()
def gitea_list_issue_comments(
issue_number: int,
limit: int = 50,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""List discussion comments on a Gitea issue.
Read-only. Issue discussion comments are distinct from PR reviews: this
reads the issue comment thread and never touches review endpoints. The
profile must allow ``gitea.read`` (fail closed otherwise).
Normal output is LLM-safe: no endpoint URLs. Set
GITEA_MCP_REVEAL_ENDPOINTS=1 (admin/debug opt-in) to include each
comment's web link.
Args:
issue_number: The issue number whose comments to list (required).
limit: Max number of comments to return (default: 50).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success', 'issue_number', and 'comments' (each with 'id',
'author', 'created_at', 'updated_at', 'body'); on a permission block,
'success' False, 'reasons', and a structured 'permission_report'
(#142) with no API call made.
"""
reasons = _profile_operation_gate("gitea.read")
if reasons:
return {"success": False, "issue_number": issue_number,
"reasons": reasons,
"permission_report": _permission_block_report("gitea.read")}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
comments = api_request("GET", api, auth) or []
reveal = _reveal_endpoints()
out = []
for c in comments[:limit]:
entry = {
"id": c["id"],
"author": (c.get("user") or {}).get("login", ""),
"created_at": c.get("created_at"),
"updated_at": c.get("updated_at"),
"body": c.get("body", ""),
}
if reveal:
entry["url"] = c.get("html_url")
out.append(entry)
return {"success": True, "issue_number": issue_number, "comments": out}
@mcp.tool()
def gitea_create_issue_comment(
issue_number: int,
body: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Post a markdown comment to a Gitea issue's discussion thread.
Issue discussion comments are distinct from PR reviews: this posts to the
issue comment thread only and never submits review verdicts. The profile
must allow ``gitea.issue.comment`` — gated separately from the gitea.pr.*
review/merge operations (fail closed otherwise). The target issue is
always explicit; there is no inference beyond the standard remote
defaults used by every tool.
Normal output is LLM-safe: comment id + issue number, no endpoint URLs.
Set GITEA_MCP_REVEAL_ENDPOINTS=1 (admin/debug opt-in) to include the
comment's web link. Errors are redacted before being raised.
Args:
issue_number: The issue number to comment on (required).
body: Markdown comment body (required, non-empty).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success', 'comment_id', and 'issue_number' ('url' only
with the reveal opt-in); on a permission block or empty body,
'success'/'performed' False and 'reasons' with no API call made
(permission blocks also carry a structured 'permission_report',
#142).
"""
verify_preflight_purity(remote)
gate_reasons = _profile_operation_gate("gitea.issue.comment")
reasons = list(gate_reasons)
if not (body or "").strip():
reasons.append("comment body must be a non-empty string")
if reasons:
blocked = {"success": False, "performed": False,
"issue_number": issue_number, "reasons": reasons}
if gate_reasons:
# Permission denial (not a mere input error): explain it (#142).
blocked["permission_report"] = _permission_block_report(
"gitea.issue.comment")
return blocked
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
try:
with _audited("create_issue_comment", host=h, remote=remote, org=o,
repo=r, issue_number=issue_number,
request_metadata={"body_chars": len(body)}):
data = api_request("POST", api, auth, {"body": body})
except Exception as exc:
raise RuntimeError(_redact(str(exc))) from None
result = {"success": True, "performed": True,
"comment_id": data["id"], "issue_number": issue_number}
if _reveal_endpoints():
result["url"] = data.get("html_url")
return result
# ── Operator guide / project skills (#128) ───────────────────────────────────
# Read-only capability discovery for new LLM sessions: what the active
# profile may do, how identity is verified, the non-negotiable safety rules,
# and which project workflows ("skills") exist. Nothing here mutates state or
# widens any permission — the guide describes the gates, it never bypasses
# them.
def _role_kind(allowed, forbidden) -> str:
"""Classify the active profile from its normalized operations.
'author' can create PRs / push branches; 'reviewer' can approve/merge;
'mixed' can do both (a config smell — the reviewer-deadlock invariant
forbids it in v2 configs); 'limited' can do neither.
"""
def can(op):
return gitea_config.check_operation(op, allowed, forbidden)[0]
review = can("gitea.pr.approve") or can("gitea.pr.merge")
author = can("gitea.pr.create") or can("gitea.branch.push")
if review and author:
return "mixed"
if review:
return "reviewer"
if author:
return "author"
return "limited"
_HARD_STOPS = [
"No self-review and no self-merge: the authenticated user must differ "
"from the PR author for review/approve/merge.",
"No tags or releases without an explicit operator instruction.",
"Never print token values, keychain IDs, passwords, or raw service "
"URLs in normal output.",
"No production access or production mutations.",
"No force-merge and no bypassing of merge gates; Gitea's own "
"mergeable signal is honoured, never overridden.",
"Do not rewrite the live profiles config; config changes are "
"operator-owned.",
"Do not hardcode Gitea identities or assume profile roles from prompts. "
"Always call gitea_whoami to verify the active identity and profile, and "
"call gitea_resolve_task_capability to check required task permissions.",
"Use the correct namespace/profile: PR review and merge require a reviewer "
"namespace (e.g. gitea-reviewer), and branch/PR creation requires an author "
"namespace (e.g. gitea-author).",
"Issue comments require explicit gitea.issue.comment permission. PR "
"comments (gitea.pr.comment) do not imply or satisfy issue comment requirements.",
]
_GUIDE_RULES = {
"hard_stops": _HARD_STOPS,
"fail_closed": (
"Every gate fails closed: unknown/disabled profiles, unknown "
"operations, empty allowed-operation lists, unresolved identity, "
"and unparseable config all deny the action instead of guessing. "
"Disabled profiles/services are never silently substituted."),
"head_sha_pinning": (
"Before reviewing or merging, record the PR head SHA and pass it as "
"expected_head_sha; if the head moves between review and merge, the "
"tool refuses and the review must be redone."),
"merge_confirmation": (
"gitea_merge_pr requires confirmation to equal 'MERGE PR <n>' "
"exactly (e.g. 'MERGE PR 42'); without it no API call is made. "
"Merge only when the operator has explicitly authorized it."),
"redaction": [
"Normal output contains no endpoint URLs, keychain IDs, or token "
"values; auth is reported as type/status only.",
"GITEA_MCP_REVEAL_ENDPOINTS=1 is the local admin/debug opt-in for "
"web links and endpoint names; it never reveals token values.",
"Errors are redacted before being surfaced.",
],
"separation": (
"Author, reviewer, and merger are separate identities. An author "
"profile creates branches/commits/PRs and must never "
"review/approve/merge its own work; a reviewer/merger profile "
"reviews and merges and must never create PRs or push branches "
"(reviewer-deadlock invariant). Issue discussion comments "
"(gitea.issue.comment) are gated separately from PR reviews "
"(gitea.pr.*)."),
"profile_switching": [
"The active profile is selected via GITEA_MCP_PROFILE (with GITEA_MCP_CONFIG pointing at the config path).",
"By default, static-profile mode is active: changing local environment variables does not dynamically update the already-connected MCP server process.",
"If runtime profile switching is explicitly enabled in the config (allow_runtime_switching: true), use gitea_activate_profile to switch profiles in-place.",
"If switching is disabled, you must change the launcher configuration/environment (dual-namespace) and reconnect/restart the MCP session.",
"After any profile switch or startup, you must verify the new runtime identity with a fresh gitea_whoami call before taking action."
],
"identity_verification": [
"Call gitea_whoami with an explicit remote first; confirm the "
"authenticated username and profile match the role you were asked "
"to perform.",
"If the username, profile, or allowed operations do not match the "
"task, STOP and report instead of proceeding.",
"Runtime identity (whoami) is the source of truth; config-declared "
"roles are metadata only.",
"Do not hardcode Gitea identities; always dynamically verify your "
"identity and resolve task capability via gitea_resolve_task_capability.",
],
"live_state_reconciliation": [
"Never rely on prior handoffs, chat history, or cached reports for PR queue state.",
"Before review or merge decisions: always call gitea_list_prs + gitea_view_pr (for the specific PR), re-fetch remotes, pull master --ff-only, and compare live fields (state, mergeable, head SHA, updated_at, merged_at, merge_commit_sha) against any prior claims.",
"Conflicting or stale PR state (e.g. prior said merged but live open, or head/updated mismatch) is a hard blocker: report it explicitly and stop until live state is unambiguous.",
"After merge, re-list PRs and re-view the PR (plus verify master contains the merge) before claiming completion.",
],
"work_selection": (
"Before any issue or PR work, acquire or verify a work lease. Do not "
"code, review, branch, commit, push, comment, or open a PR until you "
"prove the target is not already being worked. Required checks: list "
"open PRs; search PRs linked to the issue; search local/remote "
"branches for the issue number; search worktrees for the issue "
"branch; check dirty worktrees; check active leases or recent "
"handoffs; check whether a merged PR already completed the issue. If "
"another session owns the lease, stop (continue as owner, review the "
"existing PR, hand off, request takeover after expiry, or report "
"'work already claimed'). Never create a parallel branch/PR unless "
"the old branch is proven abandoned and takeover is recorded. "
"gitea_lock_issue is the fail-closed author lease gate."),
"global_worktree": (
"Main checkout is a stable control checkout on master/main/dev only. "
"All LLM task work happens under branches/. Before any mutation, prove "
"project root, cwd, branch, main-checkout stable branch, and "
"session-owned branches/ worktree path. If cwd is not under "
"branches/, stop — no edit/create/delete/format/test-write/commit/"
"merge/rebase/checkout/cleanup, with no exceptions (docs, tests, "
"small fixes, review fixes, conflicts, emergencies). Main checkout: "
"read-only inspect, fetch, create worktrees, post-merge stable "
"update, explicit repair only."),
"shell_spawn_hard_stop": (
"A shell result of exit_code: -1 with empty stdout/stderr is an "
"executor spawn failure, not a command failure. Probe once (echo/pwd); "
"if the probe fails, mark shell unavailable for the session. After "
"two consecutive spawn failures, hard-stop all shell use — never "
"retry the same failing spawn — and emit a recovery report: restart "
"the session, kill hung background terminals, prefer MCP-native "
"paths (e.g. gitea_commit_files) for remaining mutations. Shell "
"unavailability never authorizes WebFetch/browser/manual-encoding "
"fallbacks (#258)."),
"subagent_tool_budget": (
"General-purpose subagents on deterministic MCP tasks must stay within "
"budget: single-step mutations (commit_files, create_pr, lock_issue) "
"max 15 tool calls / 5 min; review/merge queue inspection max 40 / "
"15 min. The main session with gitea.repo.commit must call "
"gitea_commit_files directly — no subagent delegation (#260). After "
"shell spawn failure (#258), attempt native MCP once; never authorize "
"WebFetch/Playwright/manual base64 when gitea_commit_files is "
"available. Never resume a failed subagent into a larger retry loop "
"(#259)."),
"subagent_delegation": (
"Deterministic write workflows (issue claim, branch creation, code "
"edits, commits, PR creation, review, merge, cleanup) run inline in "
"the parent session — subagents are blocked for them unless "
"explicitly allowed with a recorded justification. An authorized "
"write subagent must inherit the full gate context: issue lock, "
"branch/worktree path, identity/profile, allowed tool class, "
"command deny list, validation ledger requirement, and final report "
"schema. Subagent output is accepted only with the same proof "
"fields as the parent workflow; read-only delegation (search, "
"inventory, summarize) needs no explicit authorization. Enforced "
"fail closed via subagent_gate.assess_subagent_delegation and "
"validate_subagent_report (#266)."),
"review_merge_state_machine": (
"PR review/merge must follow the enforced state machine: PRECHECK → "
"INVENTORY → SELECT_PR → PIN_HEAD_SHA → CREATE_BRANCHES_WORKTREE → "
"VALIDATE → REVIEW_DECISION → APPROVE_OR_REQUEST_CHANGES → "
"PRE_MERGE_RECHECK → MERGE → POST_MERGE_REPORT. Failed upstream "
"states forbid downstream approve/merge. infra_stop and capability "
"blocks stop immediately. Blocked recovery handoffs must restart the "
"full workflow — never replay approve/merge commands (#290)."),
"native_mcp_preference": (
"Gitea mutations must use native MCP tools first. When MCP is "
"available, block shell scripts, direct API calls, WebFetch, "
"Playwright, helper encoders, and profile-secret reads unless "
"explicit recovery-mode proof is complete. If MCP transport is "
"broken or the shell circuit breaker trips, emit a terminal recovery "
"report instead of improvising unsafe fallback. Reviewers must not "
"touch or restart MCP server files/processes (#270)."),
}
_COMMON_WORKFLOWS = [
"task routing: resolve task capability via gitea_resolve_task_capability "
"to check required permission/role kind and identify the safe next action.",
"work selection: acquire or verify a work lease before any issue/PR work "
"(open PRs, linked PRs, branches, worktrees, dirty worktrees, leases, "
"merged completion); stop if another session owns the lease.",
"global worktree: main checkout on master/main/dev only; mutate only "
"from a branches/ worktree after proving root, cwd, branch, stable "
"main-checkout branch, and session worktree path.",
"issue authoring: verify identity, create/claim the issue, keep scope "
"explicit (remote/org/repo).",
"implementation: claim issue, branch from fresh master, implement only "
"the issue scope, test, open a PR referencing the issue, stop.",
"PR review: verify reviewer identity (must be a reviewer profile/namespace), "
"reconcile live state first (list_prs + view_pr selected PR immediately; "
"verify state/mergeable/head SHA/updated_at/merged info vs any prior reports; "
"git fetch --prune and pull --ff-only master; check linked issue state); "
"pin the head SHA; treat conflicting/stale reports as blocker and stop; "
"validate independently, post a verdict; never review your own work. "
"After merge, re-list and re-view.",
"PR merge: reviewer identity (must be a reviewer profile/namespace) + "
"eligibility check + pinned head + explicit 'MERGE PR <n>' confirmation, "
"only with operator authorization. Reconcile live state before deciding.",
]
# Skill registry (#128). status: 'available' = backed by tools in this
# server; 'external-mcp' = implemented in another MCP server that must be
# registered separately; 'designed-not-implemented' = design exists but no MCP
# tools yet; 'operator-only' = never performed by an LLM session.
_PROJECT_SKILLS = {
"gitea-issue-authoring": {
"description": "Create, view, label, claim, and close Gitea issues.",
"when_to_use": "Filing new work, updating issue state, claiming an "
"issue before implementation (gitea_mark_issue).",
"required_operations": ["gitea.read"],
"status": "available",
"notes": "Always pass remote/org/repo explicitly. Resolve task "
"capability first to confirm author profile and "
"gitea.issue.* permissions.",
"steps": [
"Resolve task first: gitea_resolve_task_capability to confirm "
"author namespace and required permissions (e.g. for "
"create_issue or comment_issue).",
"Verify identity with gitea_whoami (explicit remote).",
"Check the issue queue with gitea_list_issues.",
"Create with gitea_create_issue or claim with gitea_mark_issue "
"action='start'.",
"Keep issue bodies free of secrets, tokens, and raw service "
"URLs.",
"Release the claim (action='done') or close when finished.",
],
},
"gitea-pr-creation": {
"description": "Author a feature branch and open a pull request.",
"when_to_use": "After implementing a claimed issue on a feature "
"branch; author profiles only.",
"required_operations": ["gitea.pr.create"],
"status": "available",
"steps": [
"Resolve task first: gitea_resolve_task_capability(task='create_pr') "
"to confirm author profile.",
"Branch from fresh master (git pull first).",
"Implement only the claimed issue's scope; stage files "
"explicitly.",
"Run targeted tests, the full suite, py_compile, git diff "
"--check, and a secret sweep before committing.",
"Open the PR with gitea_create_pr referencing the issue "
"('Closes #<n>').",
"Stop: do not review or merge your own PR.",
],
},
"gitea-pr-review": {
"description": "Independently review a pull request and post a "
"verdict.",
"when_to_use": "Reviewer profile asked to evaluate someone else's "
"PR.",
"required_operations": ["gitea.pr.review"],
"status": "available",
"steps": [
"Resolve task first: gitea_resolve_task_capability(task='review_pr') "
"to confirm reviewer namespace and avoid author-profile blocks.",
"Verify reviewer identity with gitea_whoami; the PR author "
"must be a different user.",
"Reconcile live queue state FIRST (do not trust prior handoffs): "
"call gitea_list_prs, then immediately gitea_view_pr on the target; "
"verify state, mergeable, head vs current, updated_at/merged info, "
"linked issue; git fetch --prune; git checkout master; git pull <remote> master --ff-only. "
"If prior report conflicts with live (e.g. 'merged' but live open, or head/updated mismatch), "
"report inconsistency and STOP before review/merge.",
"Pin the reviewed head SHA (gitea_view_pr) before validating.",
"Validate independently: scope vs the linked issue, tests, "
"diff check, secret sweep.",
"Post the verdict with gitea_review_pr / "
"gitea_submit_pr_review.",
"After any merge, re-list open PRs and re-view the PR to confirm landed state.",
"Do not merge unless the operator explicitly authorizes it.",
],
},
"gitea-pr-merge": {
"description": "Gated merge of an approved pull request.",
"when_to_use": "Reviewer/merger profile with explicit operator "
"authorization to merge.",
"required_operations": ["gitea.pr.merge"],
"status": "available",
"steps": [
"Resolve task first: gitea_resolve_task_capability(task='merge_pr') "
"to confirm reviewer namespace.",
"Confirm operator authorization for this specific merge.",
"Run gitea_check_pr_eligibility action='merge' and resolve "
"every reason it reports.",
"Call gitea_merge_pr with expected_head_sha pinned and "
"confirmation 'MERGE PR <n>'.",
"Confirm linked issues auto-closed and the in-progress label "
"was released.",
],
},
"gitea-issue-comments": {
"description": "Read and post issue discussion comments (distinct "
"from PR reviews).",
"when_to_use": "Design discussions, review notes on issues, "
"decision records.",
"required_operations": ["gitea.issue.comment"],
"status": "available",
"notes": "Listing needs only gitea.read; posting needs "
"gitea.issue.comment, gated separately from gitea.pr.*. "
"Resolve with gitea_resolve_task_capability first.",
"steps": [
"Resolve task first: gitea_resolve_task_capability(task='comment_issue') "
"to confirm gitea.issue.comment and correct profile/namespace.",
"List with gitea_list_issue_comments (explicit issue number).",
"Post with gitea_create_issue_comment; markdown body, no "
"secrets or raw URLs.",
"Never use PR review tools for issue discussion or vice "
"versa.",
],
},
"gitea-resolve-task-capability": {
"description": "Determine the required profile, permission, role kind, "
"and safe next action (namespace switch or relaunch) "
"for a Gitea task before acting.",
"when_to_use": "Before any mutating action, review, or comment; "
"to select correct author/reviewer namespace and "
"avoid permission failures.",
"required_operations": ["gitea.read"],
"status": "available",
"steps": [
"Call gitea_resolve_task_capability with the intended task "
"(e.g. 'create_issue', 'review_pr', 'comment_issue').",
"Inspect 'allowed_in_current_session', 'required_role_kind', "
"'matching_configured_profile', 'different_mcp_namespace_required', "
"and 'exact_safe_next_action'.",
"If not allowed, follow the exact safe next action (do not "
"hardcode identity or guess).",
"Verify the active profile matches the required role before "
"proceeding.",
],
},
"profile-switching": {
"description": "Change the active MCP identity between author and "
"reviewer profiles.",
"when_to_use": "A task requires a role the current profile "
"forbids (e.g. moving from authoring to review).",
"required_operations": [],
"status": "available",
"steps": [
"Check profile mode using gitea_get_runtime_context.",
"If dynamic-profile mode is enabled (allow_runtime_switching: true), "
"call gitea_activate_profile to switch in-place.",
"If static-profile mode (default), ask the operator to update GITEA_MCP_PROFILE "
"in the launcher environment and reconnect/restart the MCP server.",
"Immediately verify the new identity with a fresh gitea_whoami call before taking action.",
"If identity does not match the expected role, STOP.",
],
},
"redaction-security-review": {
"description": "Sweep changes and tool output for secrets, "
"keychain IDs, token values, and raw service URLs.",
"when_to_use": "Before every commit/PR and when reviewing any "
"diff.",
"required_operations": ["gitea.read"],
"status": "available",
"steps": [
"Run git diff --check for whitespace damage.",
"Grep the diff for token/password/secret/keychain patterns; "
"only synthetic fixtures may match.",
"Confirm normal tool output contains no endpoint URLs or "
"keychain IDs (reveal opt-in excepted).",
"Confirm no live config or private machine paths are being "
"committed.",
],
},
"jenkins-mcp": {
"description": "Read-only Jenkins CI inspection (jobs, builds, "
"logs).",
"when_to_use": "Checking CI state through a separately registered "
"jenkins-mcp server.",
"required_operations": ["jenkins.read"],
"status": "external-mcp",
"notes": "Server code lives in mcp-control-plane as the separate "
"jenkins-mcp server. Register that server under the exact "
"jenkins-mcp name in the MCP client, reconnect/reload the "
"client, then verify Jenkins tools are visible before use. "
"Report SKIPPED if the server is absent or exposes no usable "
"tools. Do not substitute shell/API. Trigger requires a "
"dedicated write profile and confirmation (see #56).",
"steps": [
"Confirm a Jenkins MCP server is connected under the exact "
"jenkins-mcp name.",
"Verify tool discoverability: jenkins_whoami, "
"jenkins_list_jobs, jenkins_latest_build, jenkins_build_status, "
"and jenkins_get_build must be visible before claiming Jenkins "
"readiness.",
"If the server is enabled but exposes no usable tools, report "
"SKIPPED and stop.",
"Use read-only operations only; never trigger unless using a "
"dedicated write profile plus exact confirmation.",
],
},
"glitchtip-mcp": {
"description": "Read-only GlitchTip error/event inspection.",
"when_to_use": "Investigating reported errors through a separately "
"registered glitchtip-mcp server.",
"required_operations": ["glitchtip.read"],
"status": "external-mcp",
"notes": "Server code lives in mcp-control-plane as the separate "
"glitchtip-mcp server. Register that server under the exact "
"glitchtip-mcp name in the MCP client, reconnect/reload the "
"client, then verify GlitchTip tools are visible before use. "
"Report SKIPPED if the server is absent or exposes no usable "
"tools. Filing to Gitea is a separate orchestrator tracked "
"outside this server (see #57).",
"steps": [
"Confirm a GlitchTip MCP server is connected under the exact "
"glitchtip-mcp name.",
"Verify tool discoverability: glitchtip_whoami, "
"glitchtip_list_projects, glitchtip_list_unresolved, "
"glitchtip_get_issue, glitchtip_recent_events, and "
"glitchtip_search must be visible before claiming GlitchTip "
"readiness.",
"If the server is enabled but exposes no usable tools, report "
"SKIPPED and stop.",
"Use read-only operations only; never mutate issues or settings. "
"Filing is a separate orchestrator tracked in #57.",
],
},
"release-operator": {
"description": "Release, tag, and version workflows.",
"when_to_use": "Never by an LLM session on its own: releases and "
"tags are operator-owned.",
"required_operations": [],
"status": "operator-only",
"notes": "See the release SOP docs. Hard stop: no tags or "
"releases without an explicit operator instruction.",
"steps": [
"Stop and hand off to the operator; do not create tags or "
"releases.",
],
},
}
@mcp.tool()
def mcp_get_control_plane_guide(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Structured operator guide for the current Gitea MCP session (#128).
Read-only. Call this first in a new session: it reports the active
profile, the authenticated identity (fail-soft), what this profile may
and may not do, and the non-negotiable workflow rules (hard stops,
fail-closed behavior, head-SHA pinning, merge confirmation, redaction,
author/reviewer separation, profile switching). Guidance is
profile-aware; if identity cannot be resolved it instructs the session
to STOP.
Always resolve task capability (gitea_resolve_task_capability) before
acting to determine required role/namespace. Issue comments require
gitea.issue.comment (distinct from gitea.pr.comment); review/merge
require reviewer namespace.
Args:
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
Returns:
dict with 'read_only', 'remote', 'profile' (name, operations,
role_kind), 'identity' (username/status/instruction; 'server' only
with the reveal opt-in), 'guidance' (profile-specific), 'rules',
'workflows', and 'skills_tool'. Never secrets.
"""
h, _, _ = _resolve(remote, host, None, None)
profile = get_profile()
allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"]
role = _role_kind(allowed, forbidden)
username = _authenticated_username(h)
identity = {
"authenticated_username": username,
"remote": remote,
"status": "verified" if username else "unresolved",
"instruction": (
"Identity verified — confirm it matches the role this task "
"requires before acting. If mismatched or unclear, call "
"gitea_resolve_task_capability to check the correct profile or namespace."
if username else
"STOP: the authenticated identity could not be resolved. Do "
"not perform any mutating operation; call gitea_resolve_task_capability "
"to find the required profile, report to the operator, and wait."),
}
if _reveal_endpoints():
identity["server"] = h
guidance = [
"ALWAYS resolve the task capability first: call "
"gitea_resolve_task_capability (task=...) before any action. "
"Do not hardcode identity or guess the launcher profile. "
"Verify the active identity/profile matches the required role "
"for the task. Use the correct namespace (author vs reviewer) "
"as reported."
]
if not username:
guidance.append(
"STOP: identity unresolved — no mutating operations until the "
"operator fixes credentials/profile. Call gitea_resolve_task_capability "
"to check required profile/permissions, and verify via gitea_whoami.")
if role == "author":
guidance.append(
"Author profile: creating branches, commits, issues, and PRs "
"is allowed; review, approve, and merge are forbidden for this "
"profile — never attempt them, and never review or merge your "
"own PR from any profile. Issue comments require "
"gitea.issue.comment (separate from gitea.pr.comment).")
elif role == "reviewer":
guidance.append(
"Reviewer profile: review/approve/merge may proceed ONLY after "
"gitea_check_pr_eligibility passes and the PR head SHA is "
"pinned (expected_head_sha); the PR author must be a different "
"user, and merging additionally requires explicit operator "
"authorization plus the 'MERGE PR <n>' confirmation. "
"PR comments do not imply issue comments.")
elif role == "mixed":
guidance.append(
"WARNING: this profile allows both authoring and "
"review/merge, which defeats two-party review. Treat it as "
"misconfigured: STOP and report to the operator before any "
"review or merge.")
else:
guidance.append(
"Limited profile: no authoring and no review/merge "
"operations are allowed. Read-only work only; anything else "
"fails closed.")
return {
"read_only": True,
"remote": remote,
"profile": {
"name": profile["profile_name"],
"allowed_operations": allowed,
"forbidden_operations": forbidden,
"role_kind": role,
},
"identity": identity,
"guidance": guidance,
"rules": _GUIDE_RULES,
"workflows": _COMMON_WORKFLOWS,
"skills_tool": "mcp_list_project_skills",
}
@mcp.tool()
def mcp_list_project_skills() -> dict:
"""List the project's workflow skills and when to use them (#128).
Read-only; makes no API calls. Each skill reports its name,
description, when-to-use guidance, required operations, status
('available', 'external-mcp', 'designed-not-implemented', or
'operator-only'), and whether the current profile's operations permit it. Use
mcp_get_skill_guide(name) for the step-by-step guide.
Returns:
dict with 'read_only', 'count', and 'skills' (list). Never secrets.
"""
profile = get_profile()
allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"]
skills = []
for name, meta in _PROJECT_SKILLS.items():
ops = meta["required_operations"]
available = all(
gitea_config.check_operation(op, allowed, forbidden)[0]
for op in ops
) if ops else True
entry = {
"name": name,
"description": meta["description"],
"when_to_use": meta["when_to_use"],
"required_operations": ops,
"status": meta["status"],
"available_to_current_profile": (
available and meta["status"] == "available"),
}
if meta.get("notes"):
entry["notes"] = meta["notes"]
skills.append(entry)
return {"read_only": True, "count": len(skills), "skills": skills}
@mcp.tool()
def mcp_get_skill_guide(skill_name: str) -> dict:
"""Step-by-step guide for one named project skill (#128).
Read-only; makes no API calls. Unknown skill names fail closed with
the list of valid names instead of guessing.
Args:
skill_name: A name from mcp_list_project_skills (case-insensitive).
Returns:
dict with 'success', 'skill' metadata, and 'steps'; on an unknown
name, 'success' False with 'reasons' and 'valid_skills'.
"""
key = (skill_name or "").strip().lower()
meta = _PROJECT_SKILLS.get(key)
if meta is None:
return {
"success": False,
"reasons": [f"unknown skill '{skill_name}' (fail closed)"],
"valid_skills": sorted(_PROJECT_SKILLS),
}
skill = {
"name": key,
"description": meta["description"],
"when_to_use": meta["when_to_use"],
"required_operations": meta["required_operations"],
"status": meta["status"],
}
if meta.get("notes"):
skill["notes"] = meta["notes"]
return {"success": True, "skill": skill, "steps": list(meta["steps"])}
@mcp.tool()
def gitea_whoami(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Look up the Gitea account the MCP server is authenticated as.
Read-only. Calls Gitea's authenticated-user endpoint (GET /api/v1/user)
with the configured token and returns safe identity metadata only. Use
this to prove which account a mutating workflow (e.g. review/merge) would
act as, so self-review/self-merge can be detected before acting.
Never returns the token, Authorization header, password, or any other
secret material. Fails closed with a clear error if the identity cannot
be determined.
Args:
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
Returns:
dict with 'authenticated', 'username', 'display_name', 'user_id',
'email', 'server', 'remote', and 'profile' (safe runtime profile
metadata: profile_name + allowed_operations; never the token).
"""
record_preflight_check("whoami")
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
h = host or REMOTES[remote]["host"]
auth = _auth(h)
url = gitea_url(h, "/api/v1/user")
data = api_request("GET", url, auth)
if not data or not data.get("login"):
# Fail closed: never assume an identity we could not verify.
raise RuntimeError(
f"Could not determine the authenticated Gitea identity for {h}. "
"Verify the configured token is valid for this instance."
)
# Runtime profile metadata is non-secret (name + allowed op categories).
# The token is resolved separately and is never included here. Endpoint
# URLs stay out of normal LLM-facing output (#120): the logical remote
# name is the addressing surface; 'server' appears only under the
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in.
profile = get_profile()
result = {
"authenticated": True,
"username": data.get("login"),
"display_name": data.get("full_name") or None,
"user_id": data.get("id"),
"email": data.get("email") or None,
"remote": remote,
"profile": {
"profile_name": profile["profile_name"],
"allowed_operations": profile["allowed_operations"],
"forbidden_operations": profile["forbidden_operations"],
"environment": profile.get("environment"),
"service": profile.get("service"),
"identity": profile.get("identity"),
"role": profile.get("role"),
"profile_address": profile.get("profile_path"),
"execution_profile": profile.get("execution_profile"),
"audit_label": profile.get("audit_label"),
"auth_source_type": profile.get("auth_source_type"),
},
}
if _reveal_endpoints():
result["server"] = gitea_url(h, "").rstrip("/")
return result
@mcp.tool()
def gitea_get_authenticated_user(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
return gitea_whoami(remote=remote, host=host)
@mcp.tool()
def gitea_get_current_user(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
return gitea_whoami(remote=remote, host=host)
@mcp.tool()
def gitea_get_profile(
remote: str = "dadeschools",
host: str | None = None,
resolve_identity: bool = True,
) -> dict:
"""Describe the active Gitea MCP execution profile for this runtime.
Read-only. Reports the non-secret configuration of the running MCP
process (profile name, allowed/forbidden operation categories, audit
label, auth *status*). Endpoint URLs and token source names are hidden
from normal output (#120) and appear only under the
GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Optionally resolves the
authenticated username via ``gitea_whoami``'s endpoint so an LLM can see
who this runtime acts as.
This tool never mutates Gitea and never approves, merges, comments, or
creates anything. It never returns the token value, Authorization header,
password, raw environment, or credential file paths. Identity resolution
fails soft: if it cannot be determined, ``authenticated_username`` is null
and ``identity_status`` marks it, but the profile config is still returned.
Args:
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
resolve_identity: If True, attempt a read-only identity lookup.
Returns:
dict of safe profile metadata. ``identity_status`` is one of
'verified', 'unknown', 'unavailable', or 'not_resolved'.
"""
profile = get_profile()
reveal = _reveal_endpoints()
result = {
"profile_name": profile["profile_name"],
"allowed_operations": profile["allowed_operations"],
"forbidden_operations": profile["forbidden_operations"],
"audit_label": profile["audit_label"],
"environment": profile.get("environment"),
"service": profile.get("service"),
"identity": profile.get("identity"),
"role": profile.get("role"),
"profile_address": profile.get("profile_path"),
"execution_profile": profile.get("execution_profile"),
"auth_source_type": profile.get("auth_source_type"),
# Auth is reported as a status only (#120): the token source *name*
# (env var name / keychain id) joins endpoint URLs behind the
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Token values never appear.
"auth_status": ("configured" if profile["token_source_name"]
else "unconfigured"),
"remote": remote if remote in REMOTES else None,
"authenticated_username": None,
"identity_status": "not_resolved",
}
if reveal:
result["token_source_name"] = profile["token_source_name"]
result["base_url"] = profile["base_url"]
result["server"] = None
if remote not in REMOTES:
# Mark ambiguity rather than raising: the tool stays inspectable.
result["identity_status"] = "unknown"
result["remote_error"] = f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}"
return result
h = host or REMOTES[remote]["host"]
if reveal:
result["server"] = gitea_url(h, "").rstrip("/")
if resolve_identity:
try:
auth = _auth(h)
data = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
login = (data or {}).get("login")
if login:
result["authenticated_username"] = login
result["identity_status"] = "verified"
else:
result["identity_status"] = "unknown"
except Exception:
# Fail soft for the identity field only. Never surface the error
# detail or any credential material — just mark it unavailable.
result["identity_status"] = "unavailable"
return result
_RUNTIME_CAPABILITY_TASKS = (
"create_issue",
"comment_issue",
"create_pr",
"review_pr",
"merge_pr",
"close_issue",
)
def _matching_configured_profiles(
config: dict | None,
required_permission: str,
) -> list[str]:
"""Profile names that allow *required_permission* (redacted metadata only)."""
if not config or "profiles" not in config:
return []
matches: list[str] = []
for p_name, p_data in config["profiles"].items():
if not p_data.get("enabled", True):
continue
p_allowed = p_data.get("allowed_operations") or []
p_forbidden = p_data.get("forbidden_operations") or []
p_allowed_n = []
for op in p_allowed:
try:
p_allowed_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
p_forbidden_n = []
for op in p_forbidden:
try:
p_forbidden_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
ok, _ = gitea_config.check_operation(
required_permission, p_allowed_n, p_forbidden_n
)
if ok:
matches.append(p_name)
return sorted(matches)
def _build_runtime_task_capabilities(
allowed: list[str],
forbidden: list[str],
config: dict | None,
) -> dict:
"""Per-task capability summary for role-aware runtime context (#139)."""
task_entries = []
flags: dict[str, bool] = {}
flag_keys = {
"create_issue": "can_create_issues",
"comment_issue": "can_comment_on_issues",
"create_pr": "can_author_prs",
"review_pr": "can_review_prs",
"merge_pr": "can_merge_prs",
"close_issue": "can_close_issues",
}
for task in _RUNTIME_CAPABILITY_TASKS:
permission = task_capability_map.required_permission(task)
allowed_here, _ = gitea_config.check_operation(
permission, allowed, forbidden
)
entry = {
"task": task,
"required_permission": permission,
"required_role_kind": task_capability_map.required_role(task),
"allowed_in_current_session": allowed_here,
"matching_configured_profiles": _matching_configured_profiles(
config, permission
),
}
task_entries.append(entry)
flag_name = flag_keys.get(task)
if flag_name:
flags[flag_name] = allowed_here
return {
**flags,
"issue_comment_not_implied_by_pr_comment": True,
"task_capabilities": task_entries,
}
@mcp.tool()
def gitea_assess_review_merge_state_machine(
target_state: str | None = None,
state_completion: dict[str, bool] | None = None,
pre_merge_gates: dict[str, bool] | None = None,
infra_stop: bool = False,
capability_blocked: bool = False,
recovery_handoff_text: str | None = None,
final_report_text: str | None = None,
) -> dict:
"""Read-only: assess enforced PR review/merge workflow state (#290)."""
completion = state_completion or {}
blockers = review_merge_state_machine.assess_workflow_blockers(
infra_stop=infra_stop,
capability_blocked=capability_blocked,
)
result = {
"workflow": review_merge_state_machine.workflow_status(
completion,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
),
"blockers": blockers,
"approve": review_merge_state_machine.can_approve(
completion,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
),
"merge": review_merge_state_machine.can_merge(
completion,
pre_merge_gates=pre_merge_gates,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
),
}
if target_state:
result["advancement"] = review_merge_state_machine.assess_state_advancement(
completion,
target_state=target_state,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
)
if recovery_handoff_text is not None:
result["recovery_handoff"] = (
review_merge_state_machine.assess_blocked_recovery_handoff(
recovery_handoff_text
)
)
if final_report_text is not None:
result["final_report_claims"] = (
review_merge_state_machine.assess_final_report_state_claims(
final_report_text,
state_completion=completion,
approve_completed=bool(completion.get("APPROVE_OR_REQUEST_CHANGES")),
merge_completed=bool(completion.get("MERGE")),
)
)
return result
@mcp.tool()
def gitea_assess_gitea_operation_path(
task: str,
path_kind: str | None = None,
command_or_detail: str | None = None,
mcp_available: bool = True,
mcp_tool_visible: bool = True,
recovery_mode: bool = False,
recovery_proof_complete: bool = False,
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Read-only: assess whether a proposed Gitea mutation path is allowed (#270).
Native MCP must be preferred when available. Shell scripts, direct API
calls, WebFetch, Playwright, and helper encoders are blocked unless
explicit recovery-mode proof is supplied.
"""
profile = get_profile()
role = _role_kind(
profile.get("allowed_operations") or [],
profile.get("forbidden_operations") or [],
)
return native_mcp_preference.assess_gitea_operation_path(
task=task,
path_kind=path_kind,
command_or_detail=command_or_detail,
mcp_available=mcp_available,
mcp_tool_visible=mcp_tool_visible,
recovery_mode=recovery_mode,
recovery_proof_complete=recovery_proof_complete,
role=role,
active_profile=profile.get("profile_name"),
)
@mcp.tool()
def gitea_record_shell_spawn_outcome(
exit_code: int | None = None,
stdout: str = "",
stderr: str = "",
spawn_failure: bool | None = None,
probe_attempted: bool = False,
probe_succeeded: bool | None = None,
) -> dict:
"""Record shell spawn outcome and update the session circuit breaker (#270)."""
return native_mcp_preference.record_shell_spawn_outcome(
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
spawn_failure=spawn_failure,
probe_attempted=probe_attempted,
probe_succeeded=probe_succeeded,
)
@mcp.tool()
def gitea_get_shell_health() -> dict:
"""Read-only: return shell spawn circuit-breaker state for this MCP session (#270)."""
return native_mcp_preference.shell_health_status()
@mcp.tool()
def gitea_get_runtime_context(
remote: str = "dadeschools",
host: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Read-only: explicit visibility into active profile, configuration model, and eligibility.
Reports config model shape, profile mode (static vs dynamic), and detailed blocks.
Args:
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
"""
profile = get_profile()
config = gitea_config.load_config()
reveal = _reveal_endpoints()
# Determine config model/version
if config is None:
config_model = "env-only"
elif config.get("version") == 1:
config_model = "v1"
elif config.get("version") == 2:
if "contexts" in config or config.get("shape") == "contexts":
config_model = "v2-contexts"
else:
config_model = "v2-environments"
else:
config_model = f"unknown-version-{config.get('version')}"
# Determine profile source
if os.environ.get("GITEA_PROFILE_NAME"):
profile_source = "env var"
elif gitea_config.selected_profile_name():
profile_source = "config file profile"
else:
profile_source = "default"
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
username = _authenticated_username(h) if h else None
switching = gitea_config.is_runtime_switching_enabled()
profile_mode = "dynamic-profile" if switching else "static-profile"
# Evaluate review/merge eligibility
allowed = profile["allowed_operations"] or []
forbidden = profile["forbidden_operations"] or []
# Check approve
approve_ok, _ = gitea_config.check_operation("approve", allowed, forbidden)
# Check merge
merge_ok, _ = gitea_config.check_operation("merge", allowed, forbidden)
review_merge_allowed = False
blocked_reasons = []
suggested_fix = "none"
if not username:
blocked_reasons.append("Authenticated identity is unresolved. Gitea credentials are missing or invalid.")
suggested_fix = "operator action"
elif not (approve_ok or merge_ok):
blocked_reasons.append(
f"Active profile '{profile['profile_name']}' does not permit review or merge operations."
)
if switching:
suggested_fix = "profile switch"
else:
suggested_fix = "reviewer namespace"
else:
review_merge_allowed = True
# Note that self-review and self-merge are blocked regardless of profile roles
blocked_reasons.append(
"Note: self-review and self-merge are always blocked at runtime for any PR authored by the active user."
)
safe_next_action = "None; ready for operations."
if suggested_fix == "operator action":
safe_next_action = f"Ask the operator to configure valid credentials/token for profile '{profile['profile_name']}'."
elif suggested_fix == "profile switch":
safe_next_action = "Switch to a reviewer profile by calling gitea_activate_profile with a reviewer profile."
elif suggested_fix == "reviewer namespace":
safe_next_action = (
"Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, "
"or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
)
session_capabilities = _build_runtime_task_capabilities(
allowed, forbidden, config
)
preflight = assess_preflight_status(worktree_path)
if not preflight["preflight_ready"]:
safe_next_action = (
"Complete pre-flight verification before mutating: call gitea_whoami, then "
"gitea_resolve_task_capability for the intended task. "
f"Blocked: {'; '.join(preflight['preflight_block_reasons'])}"
)
result = {
"active_profile": profile["profile_name"],
"authenticated_username": username,
"remote": remote if remote in REMOTES else None,
"config_model": config_model,
"profile_source": profile_source,
"allowed_operations": allowed,
"forbidden_operations": forbidden,
"runtime_switching_supported": switching,
"profile_mode": profile_mode,
"review_merge_allowed": review_merge_allowed,
"review_merge_blocked_reasons": blocked_reasons,
"suggested_fix": suggested_fix,
"safe_next_action": safe_next_action,
"preflight_ready": preflight["preflight_ready"],
"preflight_block_reasons": preflight["preflight_block_reasons"],
"preflight_workspace": preflight.get("preflight_workspace"),
"session_capabilities": session_capabilities,
"shell_health": native_mcp_preference.shell_health_status(),
}
if reveal and h:
result["server"] = gitea_url(h, "").rstrip("/")
return result
@mcp.tool()
def gitea_list_profiles() -> dict:
"""Read-only: list all Gitea MCP profiles with redacted metadata.
Exposes names, role kinds, enabled state, allowed/forbidden operations, and active status.
Token values, keychain IDs, and raw URLs are hidden unless GITEA_MCP_REVEAL_ENDPOINTS=1 is enabled.
"""
config = gitea_config.load_config()
reveal = _reveal_endpoints()
active_name = gitea_config.selected_profile_name() or get_profile()["profile_name"]
profiles_out = []
if config is None:
# Env-only: return the active profile
p = get_profile()
role = _role_kind(p["allowed_operations"], p["forbidden_operations"])
h = REMOTES.get("dadeschools", {}).get("host") # default host
username = _authenticated_username(h) if h else None
prof = {
"name": p["profile_name"],
"role_kind": role,
"allowed_operations": p["allowed_operations"],
"forbidden_operations": p["forbidden_operations"],
"identity_status": "verified" if username else "unresolved",
"is_active": True,
"enabled": True,
}
if reveal:
prof["token_source_name"] = p["token_source_name"]
prof["base_url"] = p["base_url"]
profiles_out.append(prof)
else:
# Load from config profiles
profiles_dict = config.get("profiles") or {}
unavailable_dict = config.get("unavailable") or {}
audit_only_profiles = config.get("audit_only_profiles") or {}
# First, process available profiles
for name, p in profiles_dict.items():
role = _role_kind(p.get("allowed_operations", []), p.get("forbidden_operations", []))
is_active = (name == active_name)
# Identity status lookup
if is_active:
h = REMOTES.get("dadeschools", {}).get("host") # default host
username = _authenticated_username(h) if h else None
identity_status = "verified" if username else "unresolved"
else:
# Safely resolve if credentials are present without networking
try:
tok = gitea_config.resolve_token(p)
identity_status = "credentials present" if tok else "missing credentials"
except Exception:
identity_status = "missing credentials"
prof = {
"name": name,
"role_kind": role,
"allowed_operations": p.get("allowed_operations", []),
"forbidden_operations": p.get("forbidden_operations", []),
"identity_status": identity_status,
"is_active": is_active,
"enabled": p.get("enabled", True),
}
if reveal:
if isinstance(p.get("auth"), dict):
prof["auth"] = p["auth"]
prof["base_url"] = p.get("base_url")
else:
if isinstance(p.get("auth"), dict):
prof["auth"] = {k: ("<redacted>" if k != "type" else v) for k, v in p["auth"].items()}
prof["base_url"] = "<redacted>"
profiles_out.append(prof)
# Process unavailable/disabled/audit-only profiles
for name, p in audit_only_profiles.items():
role = _role_kind(p.get("allowed_operations", []), p.get("forbidden_operations", []))
is_active = (name == active_name)
prof = {
"name": name,
"role_kind": role,
"allowed_operations": p.get("allowed_operations", []),
"forbidden_operations": p.get("forbidden_operations", []),
"identity_status": "unavailable (disabled)",
"is_active": is_active,
"enabled": p.get("enabled", True),
"unavailable_reason": p.get("_unavailable_reason"),
}
if reveal:
if isinstance(p.get("auth"), dict):
prof["auth"] = p["auth"]
prof["base_url"] = p.get("base_url")
else:
if isinstance(p.get("auth"), dict):
prof["auth"] = {k: ("<redacted>" if k != "type" else v) for k, v in p["auth"].items()}
prof["base_url"] = "<redacted>"
profiles_out.append(prof)
# Handle unavailable mappings that are not in audit_only
for name, reason in unavailable_dict.items():
if not any(x["name"] == name for x in profiles_out):
profiles_out.append({
"name": name,
"role_kind": "limited",
"allowed_operations": [],
"forbidden_operations": [],
"identity_status": "unavailable",
"is_active": (name == active_name),
"enabled": False,
"unavailable_reason": reason,
})
return {"profiles": profiles_out}
@mcp.tool()
def gitea_activate_profile(
profile_name: str,
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Gated profile activation. Switch the active profile in-place for this runtime session.
Only allowed if "allow_runtime_switching": true is explicitly configured.
Args:
profile_name: Profile to activate.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
"""
if not gitea_config.is_runtime_switching_enabled():
return {
"success": False,
"message": (
"Runtime profile switching is disabled in this configuration. Static profile mode is active. "
"To enable switching, set 'allow_runtime_switching': true in the config rules."
)
}
config = gitea_config.load_config()
if not config:
return {
"success": False,
"message": "No profiles configuration file is loaded. Switching is not supported in env-only mode."
}
profiles_dict = config.get("profiles") or {}
if profile_name not in profiles_dict:
return {
"success": False,
"message": f"Profile '{profile_name}' not found in loaded config."
}
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
# 1. Record before state
before_profile = get_profile()["profile_name"]
before_identity = _authenticated_username(h) if h else None
# 2. Perform switch
gitea_config._active_profile_override = profile_name
# 3. Clear identity cache to force a fresh verification
if h:
_IDENTITY_CACHE.pop(h, None)
# 4. Resolve fresh identity
after_profile = get_profile()["profile_name"]
after_identity = _authenticated_username(h) if h else None
# 4.5 Record the authorized pivot in the in-process mutation authority
# and keep the session profile lock in sync — this is the ONLY path that
# may authorize an author→reviewer role pivot.
if _MUTATION_AUTHORITY is not None:
_MUTATION_AUTHORITY["current_profile"] = after_profile
_MUTATION_AUTHORITY["current_identity"] = after_identity
_MUTATION_AUTHORITY["role_pivot_authorized"] = True
_MUTATION_AUTHORITY["role_pivot_record"] = {
"from_profile": before_profile,
"to_profile": after_profile,
"from_identity": before_identity,
"to_identity": after_identity,
}
if os.environ.get(SESSION_PROFILE_LOCK_ENV) and after_profile:
os.environ[SESSION_PROFILE_LOCK_ENV] = after_profile
# 5. Audit the switch if auditing is on
_audit(
"activate_profile",
host=h,
remote=remote,
result={"success": True, "before": before_profile, "after": after_profile},
username=after_identity,
)
return {
"success": True,
"message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).",
"before_profile": before_profile,
"before_identity": before_identity,
"after_profile": after_profile,
"after_identity": after_identity,
}
@mcp.tool()
def gitea_audit_config() -> dict:
"""Audit the configured profiles/services: enabled state, no secrets.
Read-only and local-only: loads the canonical profiles.json named by
GITEA_MCP_CONFIG and reports profile/service names, contexts, enabled
state, capabilities, auth *status*, and one-line service summaries (e.g.
``PRGS Jenkins: enabled, read-only, authenticated``). Disabled entries
are listed so they can be audited, but the server refuses to act with
them and never falls back to another profile or service.
Never includes endpoint URLs, keychain ids, token source names, or token
values. Endpoint-revealing diagnostics exist only in the local admin CLI
(``python3 gitea_config.py audit --reveal-endpoints``), never over MCP.
"""
config = gitea_config.load_config()
if config is None:
return {
"configured": False,
"message": "No GITEA_MCP_CONFIG configured; env-only mode.",
}
report = gitea_config.audit_config(config)
report["configured"] = True
report["summaries"] = gitea_config.service_summaries(config)
return report
def _post_structured_issue_comment(
*,
issue_number: int,
body: str,
remote: str,
host: str | None,
org: str | None,
repo: str | None,
audit_op: str = "create_issue_comment",
) -> dict:
"""Post an issue-thread comment after permission gates already passed."""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
with _audited(
audit_op,
host=h,
remote=remote,
org=o,
repo=r,
issue_number=issue_number,
request_metadata={"body_chars": len(body)},
):
data = api_request("POST", api, auth, {"body": body})
result = {
"success": True,
"performed": True,
"comment_id": data["id"],
"issue_number": issue_number,
}
if _reveal_endpoints():
result["url"] = data.get("html_url")
return result
@mcp.tool()
def gitea_mark_issue(
issue_number: int,
action: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
branch_name: str | None = None,
profile: str | None = None,
) -> dict:
"""Claim or release an issue via the status:in-progress label.
This is the cross-agent lock mechanism. Check before starting work.
Args:
issue_number: The issue number.
action: 'start' to claim (add label) or 'done' to release (remove label).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Active task worktree to inspect for pre-flight purity.
Returns:
dict with 'success' boolean and 'message'.
"""
if action not in ("start", "done"):
raise ValueError(f"action must be 'start' or 'done', got '{action}'")
blocked = _profile_permission_block(
task_capability_map.required_permission("mark_issue"))
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
# Find the status:in-progress label id
labels = api_request("GET", f"{base}/labels?limit=100", auth)
label_id = None
for lb in labels:
if lb["name"] == "status:in-progress":
label_id = lb["id"]
break
if label_id is None:
raise RuntimeError(
"Label 'status:in-progress' not found. "
"Run manage_labels.py to create it first."
)
if action == "start":
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number,
request_metadata={"op": "add", "label": "status:in-progress"}):
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
{"labels": [label_id]})
active_profile = (profile or get_profile().get("profile_name") or "unknown")
branch = (branch_name or "pending").strip() or "pending"
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
kind="claim",
issue_number=issue_number,
branch=branch,
phase="claimed",
profile=active_profile,
next_action="create worktree and begin implementation",
)
heartbeat = _post_structured_issue_comment(
issue_number=issue_number,
body=heartbeat_body,
remote=remote,
host=host,
org=org,
repo=repo,
audit_op="claim_heartbeat",
)
return {
"success": True,
"message": f"Issue #{issue_number} claimed.",
"heartbeat_posted": heartbeat.get("success", False),
"heartbeat_comment_id": heartbeat.get("comment_id"),
}
else:
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number,
request_metadata={"op": "remove", "label": "status:in-progress"}):
api_request("DELETE",
f"{base}/issues/{issue_number}/labels/{label_id}", auth)
return {"success": True, "message": f"Issue #{issue_number} released."}
@mcp.tool()
def gitea_post_heartbeat(
issue_number: int,
branch: str,
phase: str,
pr: str = "none",
next_action: str = "none",
blocker: str = "none",
profile: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Post a structured progress heartbeat on a claimed issue (#268)."""
blocked = _profile_permission_block(
task_capability_map.required_permission("post_heartbeat"))
if blocked:
return blocked
verify_preflight_purity(remote)
active_profile = profile or get_profile().get("profile_name")
body = issue_claim_heartbeat.format_heartbeat_body(
kind="progress",
issue_number=issue_number,
branch=branch,
phase=phase,
profile=active_profile,
pr=pr,
next_action=next_action,
blocker=blocker,
)
return _post_structured_issue_comment(
issue_number=issue_number,
body=body,
remote=remote,
host=host,
org=org,
repo=repo,
audit_op="progress_heartbeat",
)
@mcp.tool()
def gitea_reconcile_issue_claims(
state: str = "open",
stale_after_hours: int = 24,
heartbeat_lease_minutes: int = 30,
limit: int = 100,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Read-only inventory of issue claims and heartbeat lease status (#268)."""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
issues = api_get_all(f"{base}/issues?state={state}&type=issues", auth, limit=limit)
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
branches = api_get_all(f"{base}/branches", auth, limit=limit)
branch_names = [b.get("name") for b in branches if b.get("name")]
comments_by_issue: dict[int, list[dict]] = {}
for issue in issues:
if not issue_claim_heartbeat.issue_has_in_progress_label(issue):
continue
number = int(issue["number"])
api = f"{base}/issues/{number}/comments"
comments_by_issue[number] = api_request("GET", api, auth) or []
reclaim_after_minutes = max(heartbeat_lease_minutes * 2, 60)
inventory = issue_claim_heartbeat.build_claim_inventory(
issues=issues,
comments_by_issue=comments_by_issue,
open_prs=open_prs,
branch_names=branch_names,
heartbeat_lease_minutes=heartbeat_lease_minutes,
reclaim_after_minutes=reclaim_after_minutes,
)
inventory["cleanup_plan"] = issue_claim_heartbeat.build_cleanup_plan(inventory)
inventory["success"] = True
inventory["performed"] = False
return inventory
@mcp.tool()
def gitea_cleanup_stale_claims(
dry_run: bool = True,
execute_confirmed: bool = False,
heartbeat_lease_minutes: int = 30,
limit: int = 100,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Propose or execute stale-claim cleanup for phantom/reclaimable issues (#268)."""
read_block = _profile_operation_gate("gitea.read")
if read_block:
return {
"success": False,
"performed": False,
"reasons": read_block,
"permission_report": _permission_block_report("gitea.read"),
}
inventory = gitea_reconcile_issue_claims(
heartbeat_lease_minutes=heartbeat_lease_minutes,
limit=limit,
remote=remote,
host=host,
org=org,
repo=repo,
)
if not inventory.get("success"):
return inventory
plan = [
entry
for entry in inventory.get("cleanup_plan") or []
if entry.get("action") == "remove_status_in_progress_and_comment"
]
report = {
"success": True,
"dry_run": dry_run,
"performed": False,
"planned_actions": plan,
"inventory_counts": inventory.get("counts"),
}
if dry_run:
return report
if not execute_confirmed:
raise ValueError(
"execute_confirmed must be True when dry_run=False (fail closed)"
)
blocked = _profile_permission_block(
task_capability_map.required_permission("cleanup_stale_claims"))
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
labels = api_request("GET", f"{base}/labels?limit=100", auth)
label_id = next(
(lb["id"] for lb in labels if lb.get("name") == "status:in-progress"),
None,
)
if label_id is None:
raise RuntimeError("Label 'status:in-progress' not found")
actions: list[dict] = []
active_profile = get_profile().get("profile_name")
for entry in plan:
issue_number = int(entry["issue_number"])
with _audited(
"unlabel_issue",
host=h,
remote=remote,
org=o,
repo=r,
issue_number=issue_number,
request_metadata={"op": "cleanup_stale_claim", "label": "status:in-progress"},
):
api_request(
"DELETE",
f"{base}/issues/{issue_number}/labels/{label_id}",
auth,
)
cleanup_body = issue_claim_heartbeat.format_heartbeat_body(
kind="cleanup",
issue_number=issue_number,
branch="none",
phase="stale-claim-cleanup",
profile=active_profile,
next_action="issue reclaimable by queue",
blocker=entry.get("status") or "stale",
)
comment = _post_structured_issue_comment(
issue_number=issue_number,
body=cleanup_body,
remote=remote,
host=host,
org=org,
repo=repo,
audit_op="cleanup_heartbeat",
)
actions.append(
{
"issue_number": issue_number,
"label_removed": True,
"cleanup_comment_id": comment.get("comment_id"),
}
)
report["performed"] = True
report["actions"] = actions
return report
@mcp.tool()
def gitea_list_labels(
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list:
"""List all available labels in a Gitea repository.
Args:
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
list of labels.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
return api_get_all(f"{base}/labels", auth)
@mcp.tool()
def gitea_create_label(
name: str,
color: str,
description: str = "",
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Create a new label on a Gitea repository.
Args:
name: Name of the label (e.g. 'bug', 'epic').
color: HTML color code (hex, e.g. 'fbca04' or '#fbca04').
description: Description of the label.
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict containing the created label details.
"""
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
if color.startswith("#"):
color = color[1:]
payload = {
"name": name,
"color": color,
"description": description,
}
with _audited("create_label", host=h, remote=remote, org=o, repo=r,
request_metadata={"name": name}):
return api_request("POST", f"{base}/labels", auth, payload)
@mcp.tool()
def gitea_set_issue_labels(
issue_number: int,
labels: list[str],
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> list:
"""Replace all labels on a Gitea issue with a new list of label names.
Args:
issue_number: The issue number.
labels: List of label names to apply.
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
list of all labels currently applied to the issue.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("set_issue_labels"))
if blocked:
return blocked
verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
# 1. Fetch existing labels on the repo to resolve names -> IDs
existing = api_request("GET", f"{base}/labels?limit=100", auth)
name_to_id = {lb["name"]: lb["id"] for lb in existing}
# 2. Check if any requested labels do not exist, and raise error
label_ids = []
missing_labels = []
for name in labels:
if name in name_to_id:
label_ids.append(name_to_id[name])
else:
missing_labels.append(name)
if missing_labels:
raise RuntimeError(
f"The following labels do not exist on the repository: {missing_labels}. "
"Please create them first using gitea_create_label."
)
# 3. PUT the labels to the issue
with _audited("set_issue_labels", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number, request_metadata={"labels": labels}):
res = api_request("PUT", f"{base}/issues/{issue_number}/labels", auth, {"labels": label_ids})
return res
@mcp.tool()
def gitea_mirror_refs(
apply: bool = False,
force: bool = False,
) -> dict:
"""Mirror branches and tags between dadeschools and prgs Timesheet repos.
Additive only — never deletes branches or tags. Diverged branches are
skipped unless force is True.
Args:
apply: If True, actually push. If False (default), dry-run only.
force: If True, force-push diverged branches.
Returns:
dict with 'output' (script stdout) and 'return_code'.
"""
script = os.path.join(PROJECT_ROOT, "mirror_refs.sh")
args = [script]
if apply:
args.append("--apply")
if force:
args.append("--force")
result = subprocess.run(
args, capture_output=True, text=True, timeout=120,
)
return {
"output": result.stdout + result.stderr,
"return_code": result.returncode,
}
_VALIDATION_STATUSES = ("passed", "failed", "skipped", "not-run")
def build_validation_report(commands: list[dict]) -> dict:
"""Build an explicit validation report for LLM final summaries (#167).
Each entry must carry 'command' and a 'status' from 'passed' /
'failed' / 'skipped' / 'not-run'. A failed entry must carry the exact
'output'; vague language ("shell-invocation quirks") is rejected as an
unknown status and a missing failure output raises, so a report can
never hide what actually happened. Optional keys: 'reason' (for
skipped/not-run, e.g. what targeted check replaced it) and
'full_suite': True to mark the full-test-suite command —
'full_suite_passed' is True only if that entry passed, False if it
failed/was skipped/not run, and None when no entry is marked, so
full-suite success is never implied.
"""
entries = []
full_suite_passed = None
for raw in commands:
command = (raw.get("command") or "").strip()
if not command:
raise ValueError("validation entry missing 'command' (fail closed)")
status = raw.get("status")
if status not in _VALIDATION_STATUSES:
raise ValueError(
f"unknown validation status {status!r} for '{command}'; "
f"use one of {_VALIDATION_STATUSES} (fail closed)")
output = raw.get("output")
if status == "failed" and not (output or "").strip():
raise ValueError(
f"failed validation '{command}' must include the exact "
"command output (fail closed)")
entry = {"command": command, "status": status}
if output:
entry["output"] = output
if raw.get("reason"):
entry["reason"] = raw["reason"]
if raw.get("full_suite"):
entry["full_suite"] = True
full_suite_passed = status == "passed"
entries.append(entry)
label = {"passed": "PASSED", "failed": "FAILED",
"skipped": "SKIPPED", "not-run": "NOT-RUN"}
lines = []
for e in entries:
line = f"{label[e['status']]}: {e['command']}"
if e.get("output"):
line += f" — {e['output']}"
if e.get("reason"):
line += f" ({e['reason']})"
lines.append(line)
return {
"entries": entries,
"all_passed": bool(entries) and all(
e["status"] == "passed" for e in entries),
"full_suite_passed": full_suite_passed,
"summary": "\n".join(lines),
}
@mcp.tool()
def gitea_route_task_session(
task_type: str,
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Pre-task role/session router (#206).
Classify *task_type* against the active MCP profile before any mutation.
Returns ``route_result`` — only ``allowed_current_session`` permits
downstream tool use. Reviewer tasks under an author-bound session return
``wrong_role_stop`` with no fallback to author mutations.
"""
task_type = (task_type or "").strip()
profile = get_profile()
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
active_role = _role_kind(allowed, forbidden)
if not task_type:
return role_session_router.route_task_session(
"",
active_profile=profile["profile_name"],
active_role_kind=active_role,
allowed_in_current_session=False,
)
capability = None
try:
capability = gitea_resolve_task_capability(
task=task_type,
remote=remote,
host=host,
)
except ValueError:
capability = None
if capability is not None:
return role_session_router.route_task_session(
task_type,
active_profile=capability["active_profile"],
active_role_kind=active_role,
allowed_in_current_session=capability["allowed_in_current_session"],
runtime_switching_supported=capability["runtime_switching_supported"],
)
return role_session_router.route_task_session(
task_type,
active_profile=profile["profile_name"],
active_role_kind=active_role,
allowed_in_current_session=False,
)
@mcp.tool()
def gitea_resolve_task_capability(
task: str,
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Read-only: Resolve which capability, profile, and namespace is required for a Gitea task.
Helps the client or LLM determine the correct namespace or profile before acting,
and returns exact next action instructions if the current session is not authorized.
Args:
task: The task/action to check (e.g. review_pr, create_issue).
remote: Known remote instance name.
host: Optional override for the Gitea host.
"""
TASK_MAP = task_capability_map.TASK_CAPABILITY_MAP
if task not in TASK_MAP:
raise ValueError(f"Unknown task/action: '{task}' (fail closed)")
required_permission = task_capability_map.required_permission(task)
required_role = task_capability_map.required_role(task)
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
if required_role == "reviewer":
if not infra_assessment["infra_stop"]:
capability_stop_terminal.clear()
last_route = role_session_router.last_route()
if last_route and last_route.get("route_result") == role_session_router.ROUTE_INFRA_STOP:
role_session_router.clear_route_state()
elif infra_assessment["infra_stop"]:
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
detail = "; ".join(infra_assessment.get("infra_stop_reasons") or [])
next_safe_action = (
"infra_stop: Unresolved merge conflict or mid-merge state detected in "
f"MCP runtime source ({detail}). Please resolve all conflicts manually, "
"finish/abort the merge, and retry."
)
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,
"infra_stop_assessment": infra_assessment,
"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)
# Try automatic dispatch switching
_ensure_matching_profile(required_permission, required_role, remote, host)
profile = get_profile()
config = gitea_config.load_config()
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
username = _authenticated_username(h) if h else None
# Load active permissions
active_allowed = profile.get("allowed_operations") or []
active_forbidden = profile.get("forbidden_operations") or []
# Check if allowed in current session
allowed_in_current_session, _ = gitea_config.check_operation(
required_permission, active_allowed, active_forbidden
)
switching = gitea_config.is_runtime_switching_enabled()
available_in_session = allowed_in_current_session
configured = False
restart_required = False
reason_msg = None
# Find matching configured profiles
matching_profiles = []
if config and "profiles" in config:
for p_name, p_data in config["profiles"].items():
p_allowed = p_data.get("allowed_operations") or []
p_forbidden = p_data.get("forbidden_operations") or []
p_allowed_n = []
for op in p_allowed:
try:
p_allowed_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
p_forbidden_n = []
for op in p_forbidden:
try:
p_forbidden_n.append(gitea_config.normalize_operation(op))
except Exception:
pass
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
if ok:
matching_profiles.append(p_name)
configured = len(matching_profiles) > 0
available_in_session = allowed_in_current_session
if not allowed_in_current_session:
if configured and switching:
restart_required = True
available_in_session = False
reason_msg = (
f"{required_role.capitalize()} profile exists but MCP server "
"was added after session startup and is not attached."
)
elif not configured:
reason_msg = (
f"No profile configured with permission '{required_permission}'."
)
different_namespace_required = False
next_safe_action = "None; ready for operations."
if not allowed_in_current_session:
if switching:
different_namespace_required = False
next_safe_action = f"Switch to a profile that has the required permission by calling gitea_activate_profile (matching configured profiles: {matching_profiles})."
else:
different_namespace_required = True
if required_role == "reviewer":
next_safe_action = (
"Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, "
"or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
)
elif required_role == "author":
next_safe_action = (
"Switch to the author MCP session (e.g. gitea-author) which has author permissions configured, "
"or ask the operator to update GITEA_MCP_PROFILE to an author profile."
)
else:
next_safe_action = (
f"Relaunch the server with GITEA_MCP_PROFILE set to a profile that has the required permission (e.g. one of: {matching_profiles}) "
"or use the corresponding MCP namespace."
)
# Task/role alignment guards (#167): the requested task, not the
# available credential, decides what the session may do. A review/merge
# task under a non-reviewer profile must stop — not silently degrade
# into author-side mutations.
stop_required = not allowed_in_current_session
task_role_guidance = []
if required_role == "reviewer":
if allowed_in_current_session:
task_role_guidance.append(
"Review/merge task: proceed only through the gated "
"review/merge tools and their eligibility checks; "
"self-review and self-merge remain blocked.")
else:
task_role_guidance.append(
"STOP: the requested task is review/merge but the active "
"profile cannot perform it. Do not fall back to author-side "
"work — do not commit, push, edit files, or comment as "
"author unless the operator explicitly changes the task to "
"author-side remediation (task "
"'address_pr_change_requests').")
elif task == "address_pr_change_requests":
if allowed_in_current_session:
task_role_guidance.append(
"Author-side remediation: you may commit and push fixes to "
"the PR branch, but you must not submit review verdicts and "
"must not merge.")
else:
task_role_guidance.append(
"STOP: author-side remediation requires an author profile "
"with branch push permission; the active profile cannot "
"perform it.")
elif not allowed_in_current_session:
task_role_guidance.append(
"STOP: the active profile cannot perform the requested task; "
"follow exact_safe_next_action instead of improvising.")
if task == "review_pr":
init_review_decision_lock(
remote if remote in REMOTES else None,
task,
)
record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task)
result = {
"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": active_allowed,
"allowed_in_current_session": allowed_in_current_session,
"available_in_session": available_in_session,
"configured": configured,
"restart_required": restart_required,
"stop_required": stop_required or restart_required,
"task_role_guidance": task_role_guidance,
"matching_configured_profile": matching_profiles,
"runtime_switching_supported": switching,
"different_mcp_namespace_required": different_namespace_required,
"exact_safe_next_action": next_safe_action,
}
if reason_msg:
result["reason"] = reason_msg
role_session_router.sync_route_from_capability(result)
was_terminal = capability_stop_terminal.is_active()
terminal = capability_stop_terminal.sync_from_capability_result(result)
if terminal:
result["terminal_mode"] = True
result["terminal_report"] = (
capability_stop_terminal.build_terminal_report(result)
)
elif was_terminal and not (stop_required or restart_required):
result["cleared_stale_denial"] = True
return result
@mcp.tool()
def gitea_capability_stop_terminal_report() -> dict:
"""Read-only: terminal report template after reviewer capability denial (#197)."""
record = capability_stop_terminal.active_record()
if not record:
return {
"terminal_mode": False,
"reasons": ["capability stop terminal mode is not active"],
}
return capability_stop_terminal.build_terminal_report({
"requested_task": record.get("requested_task"),
"required_role_kind": record.get("required_role_kind"),
"active_profile": record.get("active_profile"),
"active_identity": record.get("active_identity"),
"stop_required": record.get("stop_required"),
"exact_safe_next_action": record.get("exact_safe_next_action"),
})
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
# Lock this session's launch profile into the environment so child CLI
# processes (e.g. review_pr.py) can detect and refuse profile
# side-channel overrides (#199).
_export_session_profile_lock()
mcp.run(transport="stdio")