5738 lines
224 KiB
Python
5738 lines
224 KiB
Python
#!/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 merged_cleanup_reconcile # 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 (1–50; 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)."),
|
||
}
|
||
|
||
_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_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,
|
||
}
|
||
|
||
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
|
||
|
||
|
||
@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,
|
||
) -> 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]})
|
||
return {"success": True, "message": f"Issue #{issue_number} claimed."}
|
||
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_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")
|