Pre-flight order tracking now compares porcelain baselines captured at process start, whoami, and capability resolution instead of treating any shared-worktree dirt as a sticky violation. Fresh gitea_whoami re-evaluates and clears violations; runtime context reports preflight_block_reasons when mutations would fail. Refs #252 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
4916 lines
192 KiB
Python
4916 lines
192 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] = []
|
|
|
|
|
|
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 _get_workspace_porcelain() -> 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
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "status", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=PROJECT_ROOT,
|
|
)
|
|
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 assess_preflight_status() -> 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"
|
|
)
|
|
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)})"
|
|
)
|
|
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": list(_preflight_whoami_violation_files),
|
|
"preflight_capability_violation_files": list(_preflight_capability_violation_files),
|
|
"preflight_reviewer_violation_files": list(_preflight_reviewer_violation_files),
|
|
}
|
|
|
|
|
|
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):
|
|
"""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 _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,
|
|
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 issue_lock_worktree # 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).
|
|
"""
|
|
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)
|
|
h, o, r = _resolve(remote, host, org, repo)
|
|
auth = _auth(h)
|
|
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)"
|
|
)
|
|
|
|
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
|
worktree_path, PROJECT_ROOT
|
|
)
|
|
git_state = issue_lock_worktree.read_worktree_git_state(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 "",
|
|
)
|
|
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}")
|
|
|
|
return {
|
|
"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,
|
|
}
|
|
|
|
|
|
@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"))
|
|
|
|
|
|
@mcp.tool()
|
|
def gitea_list_prs(
|
|
state: str = "open",
|
|
remote: str = "dadeschools",
|
|
host: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
) -> list[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.
|
|
|
|
Returns:
|
|
List of dicts with 'number', 'title', 'state', 'head', 'base',
|
|
'mergeable', 'updated_at' ('url' only with the reveal opt-in).
|
|
The additional 'updated_at' aids stale/conflicting queue detection.
|
|
"""
|
|
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}"
|
|
prs = api_get_all(url, auth)
|
|
results = []
|
|
for pr in prs:
|
|
entry = {
|
|
"number": pr["number"],
|
|
"title": pr["title"],
|
|
"state": pr["state"],
|
|
"head": pr["head"]["ref"],
|
|
"base": pr["base"]["ref"],
|
|
"mergeable": pr.get("mergeable"),
|
|
"updated_at": pr.get("updated_at"),
|
|
}
|
|
results.append(_with_optional_url(entry, pr.get("html_url")))
|
|
return results
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# 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
|
|
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)}"
|
|
],
|
|
}
|
|
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", ""),
|
|
}
|
|
|
|
|
|
@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 'content' (base64 encoded for create/update), and optionally 'sha' (required for update/delete) or 'from_path' (for rename).
|
|
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.
|
|
"""
|
|
verify_preflight_purity(remote)
|
|
h, o, r = _resolve(remote, host, org, repo)
|
|
auth = _auth(h)
|
|
url = f"{repo_api_url(h, o, r)}/contents"
|
|
|
|
payload = {
|
|
"files": 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 files],
|
|
"operations": [f.get("operation") for f in files]}):
|
|
data = api_request("POST", url, auth, payload)
|
|
return {
|
|
"success": True,
|
|
"commit": data.get("commit", {}).get("sha", ""),
|
|
"branch": data.get("branch", {}).get("name", ""),
|
|
}
|
|
|
|
|
|
# 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 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:
|
|
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
|
prs = api_get_all(url, auth)
|
|
prs_found_count = len(prs)
|
|
for pr in prs:
|
|
labels = [l["name"] for l in pr.get("labels", [])]
|
|
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") or {}).get("sha")
|
|
pr_author = (pr.get("user") or {}).get("login")
|
|
|
|
# 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"]["ref"],
|
|
"head": pr["head"]["ref"],
|
|
"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:
|
|
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=True,
|
|
)
|
|
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."}
|
|
|
|
|
|
@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 _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 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.",
|
|
],
|
|
}
|
|
|
|
_COMMON_WORKFLOWS = [
|
|
"task routing: resolve task capability via gitea_resolve_task_capability "
|
|
"to check required permission/role kind and identify the safe next action.",
|
|
"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
|
|
|
|
|
|
@mcp.tool()
|
|
def gitea_get_runtime_context(
|
|
remote: str = "dadeschools",
|
|
host: 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."
|
|
)
|
|
|
|
preflight = assess_preflight_status()
|
|
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"],
|
|
}
|
|
|
|
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,
|
|
) -> 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.
|
|
|
|
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)
|
|
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)
|
|
|
|
if required_role == "reviewer" and role_session_router.check_mid_merge():
|
|
profile = get_profile()
|
|
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
|
username = _authenticated_username(h) if h else None
|
|
next_safe_action = (
|
|
"infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source. "
|
|
"Please resolve all conflicts manually, finish/abort the merge, restart the MCP server, 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,
|
|
"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
|
|
)
|
|
|
|
# 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
|
|
restart_required = False
|
|
reason = ""
|
|
|
|
if not allowed_in_current_session:
|
|
if configured:
|
|
restart_required = True
|
|
reason = f"Reviewer profile exists but MCP server was added after session startup and is not attached."
|
|
else:
|
|
reason = f"No profile configured with permission '{required_permission}'."
|
|
|
|
switching = gitea_config.is_runtime_switching_enabled()
|
|
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,
|
|
"stop_required": stop_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,
|
|
"available_in_session": available_in_session,
|
|
"configured": configured,
|
|
"restart_required": restart_required,
|
|
"reason": reason,
|
|
}
|
|
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:
|
|
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")
|