Expose gitea-workflow / llm-project-workflow / git-pr-workflows as one workflow router in mcp_list_project_skills, add preflight + Codex install script, and document multi-runtime skill name parity.
9123 lines
344 KiB
Python
9123 lines
344 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
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
|
||
# 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
|
||
_preflight_resolved_task: str | None = None
|
||
_process_start_porcelain: str | None = None
|
||
_preflight_whoami_baseline_porcelain: str | None = None
|
||
_preflight_capability_baseline_porcelain: str | None = None
|
||
_preflight_whoami_violation_files: list[str] = []
|
||
_preflight_capability_violation_files: list[str] = []
|
||
_preflight_reviewer_violation_files: list[str] = []
|
||
|
||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
|
||
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
||
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||
|
||
import namespace_workspace_binding as nwb # noqa: E402
|
||
|
||
|
||
def _preflight_in_test_mode() -> bool:
|
||
return "pytest" in sys.modules or "unittest" in sys.modules
|
||
|
||
|
||
def _reviewer_session_worktree() -> str | None:
|
||
import reviewer_pr_lease as _reviewer_pr_lease
|
||
|
||
session = _reviewer_pr_lease.get_session_lease()
|
||
if not session:
|
||
return None
|
||
worktree = (session.get("worktree") or "").strip()
|
||
return worktree or None
|
||
|
||
|
||
def _effective_workspace_role() -> str:
|
||
"""Resolve the namespace key used for workspace binding (#510)."""
|
||
profile = get_profile()
|
||
role = _preflight_resolved_role
|
||
if not role:
|
||
role = _role_kind(
|
||
profile.get("allowed_operations") or [],
|
||
profile.get("forbidden_operations") or [],
|
||
)
|
||
return nwb.normalize_role_kind(
|
||
role,
|
||
profile_name=profile.get("profile_name"),
|
||
)
|
||
|
||
|
||
def _actual_profile_role() -> str:
|
||
"""Resolve the workspace role from the ACTIVE PROFILE alone (#540).
|
||
|
||
Unlike :func:`_effective_workspace_role`, this never consults
|
||
``_preflight_resolved_role``. A task capability whose ``required_role_kind``
|
||
is ``author`` (e.g. ``comment_issue``) stamps ``_preflight_resolved_role =
|
||
"author"``; the #274/#475 role exemptions must key off the real profile
|
||
identity so that stamp cannot poison a genuine reviewer/merger/reconciler
|
||
session into being treated as an author. Author profiles still classify as
|
||
``author`` here, so author blocking is preserved.
|
||
"""
|
||
profile = get_profile()
|
||
role = _role_kind(
|
||
profile.get("allowed_operations") or [],
|
||
profile.get("forbidden_operations") or [],
|
||
)
|
||
return nwb.normalize_role_kind(
|
||
role,
|
||
profile_name=profile.get("profile_name"),
|
||
)
|
||
|
||
|
||
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||
"""Resolve the namespace-scoped workspace root inspected by pre-flight guards."""
|
||
role = _effective_workspace_role()
|
||
workspace, _source = nwb.resolve_namespace_workspace(
|
||
role_kind=role,
|
||
worktree_path=worktree_path,
|
||
process_project_root=PROJECT_ROOT,
|
||
session_lease_worktree=(
|
||
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
|
||
),
|
||
profile_name=get_profile().get("profile_name"),
|
||
)
|
||
return workspace
|
||
|
||
|
||
def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict:
|
||
"""Canonical namespace workspace + repository root for guards (#460/#510)."""
|
||
role = _effective_workspace_role()
|
||
return nwb.resolve_namespace_mutation_context(
|
||
role_kind=role,
|
||
worktree_path=worktree_path,
|
||
process_project_root=PROJECT_ROOT,
|
||
session_lease_worktree=(
|
||
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
|
||
),
|
||
profile_name=get_profile().get("profile_name"),
|
||
)
|
||
|
||
|
||
def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict:
|
||
"""Backward-compatible alias for namespace workspace context."""
|
||
return _resolve_namespace_mutation_context(worktree_path)
|
||
|
||
|
||
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_git_root(path: str) -> str | None:
|
||
try:
|
||
res = subprocess.run(
|
||
["git", "-C", path, "rev-parse", "--show-toplevel"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
except Exception:
|
||
return None
|
||
if res.returncode != 0:
|
||
return None
|
||
return (res.stdout or "").strip() or None
|
||
|
||
|
||
def _get_workspace_porcelain(worktree_path: str | None = None) -> str:
|
||
"""Return tracked-workspace porcelain for pre-flight attribution."""
|
||
if os.environ.get("GITEA_TEST_FORCE_DIRTY"):
|
||
return " M __gitea_test_force_dirty__.py\n"
|
||
override = os.environ.get("GITEA_TEST_PORCELAIN")
|
||
if override is not None:
|
||
return override
|
||
workspace = _resolve_preflight_workspace_path(worktree_path)
|
||
try:
|
||
res = subprocess.run(
|
||
["git", "status", "--porcelain"],
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=workspace,
|
||
)
|
||
return res.stdout or ""
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _parse_porcelain_entries(porcelain: str) -> dict[str, str]:
|
||
"""Map tracked path -> full porcelain line (untracked ``??`` ignored)."""
|
||
entries: dict[str, str] = {}
|
||
for line in (porcelain or "").splitlines():
|
||
if not line or len(line) < 4 or line.startswith("??"):
|
||
continue
|
||
path = line[3:].strip()
|
||
if " -> " in path:
|
||
path = path.split(" -> ", 1)[1].strip()
|
||
if path:
|
||
entries[path] = line
|
||
return entries
|
||
|
||
|
||
def _new_tracked_changes_since(baseline: str, current: str) -> list[str]:
|
||
"""Tracked paths that are new or changed since *baseline* porcelain."""
|
||
base = _parse_porcelain_entries(baseline)
|
||
cur = _parse_porcelain_entries(current)
|
||
changed = [
|
||
path for path, line in cur.items()
|
||
if path not in base or base[path] != line
|
||
]
|
||
return sorted(changed)
|
||
|
||
|
||
def _format_preflight_files(files: list[str]) -> str:
|
||
if not files:
|
||
return "(none)"
|
||
return ", ".join(files)
|
||
|
||
|
||
def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
|
||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||
workspace = ctx["workspace_path"]
|
||
inspected_root = _get_git_root(workspace)
|
||
process_root = ctx["process_project_root"]
|
||
canonical_root = ctx["canonical_repo_root"]
|
||
active_root = os.path.realpath(inspected_root or workspace)
|
||
if active_root == canonical_root:
|
||
dirty_scope = "control checkout"
|
||
else:
|
||
dirty_scope = "active task workspace"
|
||
details = {
|
||
"mcp_server_process_root": process_root,
|
||
"canonical_repository_root": canonical_root,
|
||
"active_task_workspace_root": active_root,
|
||
"inspected_git_root": inspected_root,
|
||
"dirty_files": list(dirty_files),
|
||
"dirty_scope": dirty_scope,
|
||
"workspace_roots_aligned": ctx["roots_aligned"],
|
||
"workspace_role_kind": ctx.get("workspace_role_kind"),
|
||
"workspace_binding_source": ctx.get("workspace_binding_source"),
|
||
"ignored_bindings": list(ctx.get("ignored_bindings") or []),
|
||
}
|
||
if not ctx["roots_aligned"]:
|
||
details["workspace_root_mismatch"] = (
|
||
"runtime_context and mutation guard use canonical repository root "
|
||
f"'{canonical_root}' instead of MCP process root '{process_root}'"
|
||
)
|
||
return details
|
||
|
||
|
||
def _format_preflight_workspace_details(details: dict) -> str:
|
||
parts = [
|
||
f"MCP server process root: {details.get('mcp_server_process_root')}",
|
||
f"active task workspace root: {details.get('active_task_workspace_root')}",
|
||
f"workspace role: {details.get('workspace_role_kind')}",
|
||
f"binding source: {details.get('workspace_binding_source')}",
|
||
f"inspected git root: {details.get('inspected_git_root')}",
|
||
f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}",
|
||
f"dirty scope: {details.get('dirty_scope')}",
|
||
]
|
||
ignored = details.get("ignored_bindings") or []
|
||
if ignored:
|
||
parts.append(f"ignored foreign bindings: {'; '.join(ignored)}")
|
||
return "; ".join(parts)
|
||
|
||
|
||
def assess_preflight_status(worktree_path: str | None = None) -> dict:
|
||
"""Non-throwing pre-flight readiness for runtime-context alignment (#252)."""
|
||
reasons: list[str] = []
|
||
if not _preflight_whoami_called:
|
||
reasons.append(
|
||
"Identity (gitea_whoami) has not been verified"
|
||
)
|
||
if not _preflight_capability_called:
|
||
reasons.append(
|
||
"Task capability (gitea_resolve_task_capability) has not been resolved"
|
||
)
|
||
workspace_details = None
|
||
if worktree_path:
|
||
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
|
||
workspace_details = _preflight_workspace_details(worktree_path, dirty_files)
|
||
if dirty_files:
|
||
reasons.append(
|
||
"Active task workspace has tracked file edits before mutation "
|
||
f"({_format_preflight_workspace_details(workspace_details)})"
|
||
)
|
||
role = _effective_workspace_role()
|
||
if role in nwb.NON_AUTHOR_ROLES:
|
||
binding = nwb.assess_metadata_only_worktree_binding(
|
||
role_kind=role,
|
||
declared_worktree_path=worktree_path,
|
||
mutation_workspace=_resolve_preflight_workspace_path(worktree_path),
|
||
process_project_root=PROJECT_ROOT,
|
||
profile_name=get_profile().get("profile_name"),
|
||
)
|
||
if binding.get("block"):
|
||
reasons.append(binding["reasons"][0])
|
||
reasons.append(
|
||
nwb.format_namespace_workspace_binding_error(
|
||
role_kind=role,
|
||
workspace_path=binding["mutation_workspace"],
|
||
binding_source="MCP server process root (default)",
|
||
reasons=binding.get("reasons"),
|
||
)
|
||
)
|
||
return {
|
||
"preflight_ready": not reasons,
|
||
"preflight_block_reasons": reasons,
|
||
"preflight_whoami_verified": _preflight_whoami_called,
|
||
"preflight_capability_resolved": _preflight_capability_called,
|
||
"preflight_whoami_violation_files": [],
|
||
"preflight_capability_violation_files": [],
|
||
"preflight_reviewer_violation_files": [],
|
||
"preflight_workspace": workspace_details,
|
||
}
|
||
if _preflight_whoami_violation:
|
||
reasons.append(
|
||
"Workspace file edits occurred before gitea_whoami verification "
|
||
f"(offending files: {_format_preflight_files(_preflight_whoami_violation_files)})"
|
||
)
|
||
if _preflight_capability_violation:
|
||
reasons.append(
|
||
"Workspace file edits occurred before gitea_resolve_task_capability verification "
|
||
f"(offending files: {_format_preflight_files(_preflight_capability_violation_files)})"
|
||
)
|
||
if _preflight_reviewer_violation_files:
|
||
reasons.append(
|
||
"Reviewer profile modified tracked workspace files after capability resolution "
|
||
f"(offending files: {_format_preflight_files(_preflight_reviewer_violation_files)})"
|
||
)
|
||
warnings: list[str] = []
|
||
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
|
||
_get_workspace_porcelain()
|
||
)
|
||
if agent_artifacts:
|
||
warnings.append(
|
||
"Agent temp artifacts detected at repo root (delete before mutations): "
|
||
f"{_format_preflight_files(agent_artifacts)}"
|
||
)
|
||
return {
|
||
"preflight_ready": not reasons,
|
||
"preflight_block_reasons": reasons,
|
||
"preflight_warnings": warnings,
|
||
"preflight_whoami_verified": _preflight_whoami_called,
|
||
"preflight_capability_resolved": _preflight_capability_called,
|
||
"preflight_whoami_violation_files": list(_preflight_whoami_violation_files),
|
||
"preflight_capability_violation_files": list(_preflight_capability_violation_files),
|
||
"preflight_reviewer_violation_files": list(_preflight_reviewer_violation_files),
|
||
"preflight_workspace": _preflight_workspace_details(None, []),
|
||
}
|
||
|
||
|
||
def _clear_preflight_capability_state() -> None:
|
||
"""Drop resolved capability proof (consumed by a mutation or fresh resolve)."""
|
||
global _preflight_capability_called, _preflight_capability_violation
|
||
global _preflight_resolved_task
|
||
global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files
|
||
global _preflight_reviewer_violation_files
|
||
|
||
_preflight_capability_called = False
|
||
_preflight_capability_violation = False
|
||
_preflight_capability_violation_files = []
|
||
_preflight_capability_baseline_porcelain = None
|
||
_preflight_resolved_task = None
|
||
_preflight_reviewer_violation_files = []
|
||
|
||
|
||
def record_preflight_check(
|
||
type_name: str,
|
||
resolved_role: str | None = None,
|
||
resolved_task: 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, _preflight_resolved_task
|
||
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":
|
||
# Re-evaluate whoami violations instead of replaying sticky state (#252).
|
||
# Interleaved read-only whoami must not clear a valid capability proof (#469).
|
||
saved_capability = None
|
||
preserve_capability = (
|
||
_preflight_capability_called
|
||
and _preflight_whoami_called
|
||
and not _preflight_whoami_violation
|
||
)
|
||
if preserve_capability:
|
||
saved_capability = (
|
||
_preflight_capability_violation,
|
||
list(_preflight_capability_violation_files),
|
||
_preflight_capability_baseline_porcelain,
|
||
_preflight_resolved_role,
|
||
_preflight_resolved_task,
|
||
)
|
||
else:
|
||
_clear_preflight_capability_state()
|
||
|
||
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
|
||
|
||
if (
|
||
preserve_capability
|
||
and saved_capability is not None
|
||
and not whoami_delta
|
||
):
|
||
(
|
||
_preflight_capability_violation,
|
||
_preflight_capability_violation_files,
|
||
_preflight_capability_baseline_porcelain,
|
||
_preflight_resolved_role,
|
||
_preflight_resolved_task,
|
||
) = saved_capability
|
||
_preflight_capability_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
|
||
if resolved_task:
|
||
_preflight_resolved_task = resolved_task
|
||
|
||
|
||
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
|
||
"""#274: author file/branch mutations must run from a branches/ worktree.
|
||
|
||
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
|
||
is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
||
(#468). Non-author namespaces use dedicated workspace env vars (#510).
|
||
|
||
The exemption honours BOTH the effective workspace role and the actual
|
||
profile role (#540). ``comment_issue`` preflight stamps
|
||
``_preflight_resolved_role = "author"`` (its ``required_role_kind``), which
|
||
would otherwise poison :func:`_effective_workspace_role` into classifying a
|
||
genuine reconciler as an author and defeat this exemption. Keying off the
|
||
real profile role as well preserves the exemption without weakening author
|
||
blocking — an actual author profile classifies as ``author`` in both.
|
||
"""
|
||
if (
|
||
_effective_workspace_role() in nwb.NON_AUTHOR_ROLES
|
||
or _actual_profile_role() in nwb.NON_AUTHOR_ROLES
|
||
):
|
||
return
|
||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||
workspace = ctx["workspace_path"]
|
||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||
assessment = author_mutation_worktree.assess_author_mutation_worktree(
|
||
workspace_path=workspace,
|
||
project_root=ctx["canonical_repo_root"],
|
||
current_branch=git_state.get("current_branch"),
|
||
)
|
||
if assessment["block"]:
|
||
raise RuntimeError(
|
||
author_mutation_worktree.format_author_mutation_worktree_error(assessment)
|
||
)
|
||
|
||
|
||
def verify_preflight_purity(
|
||
remote: str | None = None,
|
||
worktree_path: str | None = None,
|
||
task: 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 (
|
||
task is not None
|
||
and _preflight_resolved_task is not None
|
||
and task != _preflight_resolved_task
|
||
):
|
||
raise RuntimeError(
|
||
"Pre-flight task mismatch: "
|
||
f"resolved '{_preflight_resolved_task}' but mutation requires "
|
||
f"'{task}' (fail closed)"
|
||
)
|
||
|
||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||
workspace = ctx["workspace_path"]
|
||
canonical_root = ctx["canonical_repo_root"]
|
||
process_root = ctx["process_project_root"]
|
||
real_workspace = os.path.realpath(workspace)
|
||
role = ctx.get("workspace_role_kind") or _effective_workspace_role()
|
||
|
||
if real_workspace != process_root:
|
||
if not _preflight_in_test_mode():
|
||
membership = author_mutation_worktree.assess_workspace_repo_membership(
|
||
workspace_path=workspace,
|
||
canonical_repo_root=canonical_root,
|
||
)
|
||
if membership["block"]:
|
||
raise RuntimeError(
|
||
author_mutation_worktree.format_workspace_repo_membership_error(
|
||
membership
|
||
)
|
||
)
|
||
|
||
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace)))
|
||
if dirty_files:
|
||
raise RuntimeError(
|
||
nwb.format_namespace_workspace_binding_error(
|
||
role_kind=role,
|
||
workspace_path=workspace,
|
||
binding_source=ctx.get("workspace_binding_source")
|
||
or "unknown binding source",
|
||
dirty_files=dirty_files,
|
||
ignored_bindings=ctx.get("ignored_bindings"),
|
||
)
|
||
)
|
||
else:
|
||
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 role in {"reviewer", "merger"}:
|
||
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(
|
||
f"{role.title()} role violation: profile is forbidden from modifying "
|
||
"tracked workspace files (fail closed). Offending files: "
|
||
f"{_format_preflight_files(reviewer_delta)}"
|
||
)
|
||
|
||
_enforce_root_checkout_guard(worktree_path)
|
||
_enforce_branches_only_author_mutation(worktree_path)
|
||
_clear_preflight_capability_state()
|
||
|
||
|
||
def _verify_role_mutation_workspace(
|
||
remote: str | None = None,
|
||
*,
|
||
worktree_path: str | None = None,
|
||
worktree: str | None = None,
|
||
task: str | None = None,
|
||
) -> str:
|
||
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
|
||
# Check running runtimes to prevent stale mutations
|
||
try:
|
||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||
config = gitea_config.load_config()
|
||
required_permission = task_capability_map.required_permission(task) if task else None
|
||
matching_profiles = []
|
||
if required_permission and 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)
|
||
runtime_reasons = _check_mcp_runtimes_diagnostics(task or "unknown", matching_profiles)
|
||
if runtime_reasons:
|
||
raise RuntimeError("; ".join(runtime_reasons))
|
||
except Exception as exc:
|
||
if "stale-runtime:" in str(exc):
|
||
raise RuntimeError(str(exc))
|
||
pass
|
||
|
||
role = _effective_workspace_role()
|
||
git_state = issue_lock_worktree.read_worktree_git_state(
|
||
_resolve_preflight_workspace_path(worktree_path)
|
||
)
|
||
assessment = nwb.assess_namespace_mutation_workspace(
|
||
role_kind=role,
|
||
worktree_path=worktree_path,
|
||
worktree=worktree,
|
||
process_project_root=PROJECT_ROOT,
|
||
session_lease_worktree=(
|
||
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
|
||
),
|
||
profile_name=get_profile().get("profile_name"),
|
||
current_branch=git_state.get("current_branch"),
|
||
)
|
||
if assessment["block"]:
|
||
raise RuntimeError(
|
||
nwb.format_namespace_workspace_binding_error(
|
||
role_kind=role,
|
||
workspace_path=assessment["mutation_workspace"],
|
||
binding_source=assessment.get("workspace_binding_source")
|
||
or "unknown binding source",
|
||
reasons=assessment.get("reasons"),
|
||
ignored_bindings=assessment.get("ignored_bindings"),
|
||
)
|
||
)
|
||
resolved = assessment["mutation_workspace"]
|
||
verify_preflight_purity(remote, worktree_path=resolved, task=task)
|
||
return resolved
|
||
|
||
|
||
def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None:
|
||
"""#475: fail closed when the stable control checkout is contaminated."""
|
||
ctx = _resolve_author_mutation_context(worktree_path)
|
||
canonical_root = ctx["canonical_repo_root"]
|
||
workspace = ctx["workspace_path"]
|
||
git_state = issue_lock_worktree.read_worktree_git_state(canonical_root)
|
||
remote_master_sha = root_checkout_guard.resolve_remote_master_sha(canonical_root)
|
||
assessment = root_checkout_guard.assess_root_checkout_guard(
|
||
workspace_path=workspace,
|
||
canonical_repo_root=canonical_root,
|
||
current_branch=git_state.get("current_branch"),
|
||
head_sha=git_state.get("head_sha"),
|
||
porcelain_status=git_state.get("porcelain_status") or "",
|
||
remote_master_sha=remote_master_sha,
|
||
resolved_role=_preflight_resolved_role,
|
||
actual_role=_actual_profile_role(),
|
||
)
|
||
if assessment["block"]:
|
||
raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment))
|
||
|
||
|
||
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||
|
||
from gitea_auth import ( # noqa: E402
|
||
REMOTES,
|
||
get_credentials,
|
||
get_auth_header,
|
||
api_request,
|
||
api_get_all,
|
||
api_fetch_page,
|
||
repo_api_url,
|
||
get_profile,
|
||
gitea_url,
|
||
)
|
||
import gitea_audit # noqa: E402
|
||
import gitea_config # noqa: E402
|
||
import capability_stop_terminal # noqa: E402
|
||
import issue_duplicate_gate # noqa: E402
|
||
import role_session_router # noqa: E402
|
||
import role_namespace_gate # noqa: E402
|
||
import task_capability_map # noqa: E402
|
||
import review_proofs # noqa: E402
|
||
import review_workflow_boundary # noqa: E402
|
||
import review_workflow_load # noqa: E402
|
||
import mcp_session_state # noqa: E402
|
||
import agent_temp_artifacts
|
||
import issue_lock_worktree # noqa: E402
|
||
import issue_lock_provenance # noqa: E402
|
||
import issue_lock_store # noqa: E402
|
||
import issue_lock_adoption # noqa: E402
|
||
import stacked_pr_support # noqa: E402
|
||
import merge_approval_gate # noqa: E402
|
||
import already_landed_reconcile # noqa: E402
|
||
import author_mutation_worktree # noqa: E402
|
||
import root_checkout_guard # noqa: E402
|
||
import remote_repo_guard # noqa: E402
|
||
import issue_claim_heartbeat # noqa: E402
|
||
import issue_work_duplicate_gate # noqa: E402
|
||
import reviewer_pr_lease # noqa: E402
|
||
import merger_lease_adoption # noqa: E402
|
||
import merged_cleanup_reconcile # noqa: E402
|
||
import worktree_cleanup_audit # noqa: E402
|
||
import reconciler_profile # noqa: E402
|
||
import reconciliation_workflow # noqa: E402
|
||
import audit_reconciliation_mode # noqa: E402
|
||
import review_merge_state_machine # noqa: E402
|
||
import pr_work_lease # noqa: E402
|
||
import workflow_skill # noqa: E402
|
||
import native_mcp_preference # noqa: E402
|
||
import worktree_cleanup_audit # noqa: E402
|
||
|
||
|
||
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
|
||
# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer.
|
||
# Legacy global path retained only for test/doc references — do not seed manually.
|
||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||
WORK_LEASE_TTL_HOURS = 4
|
||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
||
VALID_WORK_LEASE_OPERATIONS = frozenset({
|
||
AUTHOR_ISSUE_WORK_LEASE,
|
||
"review_pr_work",
|
||
"fix_pr_changes",
|
||
"cleanup_branch_work",
|
||
"issue_filing_work",
|
||
"recovery_work",
|
||
})
|
||
|
||
|
||
def _work_lease_now() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def _work_lease_timestamp(value: datetime) -> str:
|
||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
|
||
text = (value or "").strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _load_existing_issue_lock(
|
||
*,
|
||
remote: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
issue_number: int | None = None,
|
||
) -> dict | None:
|
||
if remote and org and repo and issue_number is not None:
|
||
return issue_lock_store.load_issue_lock(
|
||
remote=remote,
|
||
org=org,
|
||
repo=repo,
|
||
issue_number=issue_number,
|
||
)
|
||
return issue_lock_store.read_session_issue_lock()
|
||
|
||
|
||
def _resolve_issue_lock_for_pr(
|
||
*,
|
||
remote: str,
|
||
org: str,
|
||
repo: str,
|
||
head: str,
|
||
) -> dict:
|
||
lock_data = issue_lock_store.read_session_issue_lock()
|
||
if not lock_data:
|
||
lock_data = issue_lock_store.find_lock_for_branch(
|
||
remote=remote,
|
||
org=org,
|
||
repo=repo,
|
||
branch_name=head,
|
||
)
|
||
if not lock_data:
|
||
raise RuntimeError(
|
||
"Issue lock is missing (fail closed). Call gitea_lock_issue first."
|
||
)
|
||
return lock_data
|
||
|
||
|
||
def _save_issue_lock(data: dict) -> str:
|
||
existing = issue_lock_store.load_issue_lock(
|
||
remote=str(data.get("remote") or ""),
|
||
org=str(data.get("org") or ""),
|
||
repo=str(data.get("repo") or ""),
|
||
issue_number=int(data.get("issue_number") or 0),
|
||
)
|
||
overwrite_block = issue_lock_store.assess_foreign_lock_overwrite(existing, data)
|
||
if overwrite_block:
|
||
raise RuntimeError(overwrite_block)
|
||
try:
|
||
return issue_lock_store.bind_session_lock(data)
|
||
except Exception as e:
|
||
raise RuntimeError(f"Could not write issue lock file: {e}") from e
|
||
|
||
|
||
def _work_lease_claimant(host: str | None) -> dict:
|
||
profile = get_profile()
|
||
username = _IDENTITY_CACHE.get(host) if host else None
|
||
if username is None and host and not _preflight_in_test_mode():
|
||
username = _authenticated_username(host)
|
||
return {
|
||
"username": username,
|
||
"profile": profile.get("profile_name"),
|
||
}
|
||
|
||
|
||
def _build_author_issue_work_lease(
|
||
*,
|
||
issue_number: int,
|
||
branch_name: str,
|
||
worktree_path: str,
|
||
host: str | None,
|
||
) -> dict:
|
||
created = _work_lease_now()
|
||
expires = created + timedelta(hours=WORK_LEASE_TTL_HOURS)
|
||
return {
|
||
"operation_type": AUTHOR_ISSUE_WORK_LEASE,
|
||
"issue_number": issue_number,
|
||
"pr_number": None,
|
||
"branch": branch_name,
|
||
"worktree_path": worktree_path,
|
||
"claimant": _work_lease_claimant(host),
|
||
"created_at": _work_lease_timestamp(created),
|
||
"expires_at": _work_lease_timestamp(expires),
|
||
"last_heartbeat_at": _work_lease_timestamp(created),
|
||
}
|
||
|
||
|
||
def _active_work_lease_block(
|
||
existing_lock: dict | None,
|
||
*,
|
||
issue_number: int,
|
||
branch_name: str,
|
||
worktree_path: str,
|
||
operation_type: str,
|
||
) -> str | None:
|
||
if not existing_lock:
|
||
return None
|
||
existing_issue = existing_lock.get("issue_number")
|
||
existing_branch = existing_lock.get("branch_name")
|
||
existing_worktree = existing_lock.get("worktree_path")
|
||
lease = existing_lock.get("work_lease")
|
||
existing_operation = (
|
||
lease.get("operation_type")
|
||
if isinstance(lease, dict)
|
||
else AUTHOR_ISSUE_WORK_LEASE
|
||
)
|
||
if existing_issue != issue_number or existing_operation != operation_type:
|
||
return None
|
||
|
||
same_owner = (
|
||
existing_branch == branch_name
|
||
and os.path.realpath(str(existing_worktree or "")) == os.path.realpath(worktree_path)
|
||
)
|
||
expires_at = (
|
||
_parse_work_lease_timestamp(lease.get("expires_at"))
|
||
if isinstance(lease, dict)
|
||
else None
|
||
)
|
||
if expires_at and expires_at <= _work_lease_now():
|
||
return (
|
||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||
"Recovery review is required before takeover (fail closed)"
|
||
)
|
||
if same_owner:
|
||
return None
|
||
return (
|
||
f"Issue #{issue_number} already has an active {operation_type} lease on "
|
||
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
|
||
"(fail closed)"
|
||
)
|
||
|
||
|
||
def _branch_entry_name(branch: dict | str) -> str:
|
||
if isinstance(branch, str):
|
||
return branch
|
||
if not isinstance(branch, dict):
|
||
return ""
|
||
return str(branch.get("name") or branch.get("ref") or "")
|
||
|
||
|
||
def _live_fetch_issue_duplicate_context(
|
||
h: str,
|
||
o: str,
|
||
r: str,
|
||
auth: str,
|
||
issue_number: int,
|
||
) -> tuple[list[dict], list[str], dict]:
|
||
"""Live open PRs, remote branch names, and claim state for one issue."""
|
||
base = repo_api_url(h, o, r)
|
||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||
branches = api_get_all(f"{base}/branches", auth)
|
||
branch_names = [_branch_entry_name(b) for b in branches]
|
||
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
|
||
comments = api_request(
|
||
"GET", f"{base}/issues/{issue_number}/comments", auth
|
||
) or []
|
||
claim_entry = issue_claim_heartbeat.classify_issue_claim(
|
||
issue=issue,
|
||
comments=comments,
|
||
open_prs=open_prs,
|
||
branch_names=branch_names,
|
||
)
|
||
return open_prs, branch_names, claim_entry
|
||
|
||
|
||
# Injectable duplicate-work context fetcher (#400). Production uses the live
|
||
# Gitea API path above; unit tests patch this symbol instead of hitting the
|
||
# network.
|
||
issue_duplicate_context_fetcher = _live_fetch_issue_duplicate_context
|
||
|
||
|
||
def _collect_issue_duplicate_context(
|
||
h: str,
|
||
o: str,
|
||
r: str,
|
||
auth: str,
|
||
issue_number: int,
|
||
) -> tuple[list[dict], list[str], dict]:
|
||
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
|
||
|
||
|
||
def _assess_issue_duplicate_gate(
|
||
issue_number: int,
|
||
*,
|
||
h: str,
|
||
o: str,
|
||
r: str,
|
||
auth: str,
|
||
locked_branch: str | None = None,
|
||
phase: str,
|
||
) -> dict:
|
||
open_prs, branch_names, claim_entry = _collect_issue_duplicate_context(
|
||
h, o, r, auth, issue_number
|
||
)
|
||
return issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||
issue_number,
|
||
open_prs=open_prs,
|
||
branch_names=branch_names,
|
||
claim_entry=claim_entry,
|
||
locked_branch=locked_branch,
|
||
phase=phase,
|
||
)
|
||
|
||
|
||
def _duplicate_gate_block_response(gate: dict, **extra) -> dict:
|
||
out = {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": list(gate.get("reasons") or []),
|
||
"duplicate_gate": gate,
|
||
"safe_next_action": gate.get("safe_next_action"),
|
||
}
|
||
out.update(extra)
|
||
return out
|
||
|
||
|
||
def _enforce_locked_issue_duplicate_recheck(
|
||
remote: str,
|
||
phase: str,
|
||
*,
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict | None:
|
||
"""Re-check duplicate-work gates for the locked issue (#400)."""
|
||
lock_data = _load_existing_issue_lock()
|
||
if not lock_data:
|
||
return None
|
||
issue_number = int(lock_data.get("issue_number") or 0)
|
||
locked_branch = lock_data.get("branch_name")
|
||
if not issue_number:
|
||
return None
|
||
h, o, r = _resolve(
|
||
remote or lock_data.get("remote") or "dadeschools",
|
||
host or lock_data.get("host"),
|
||
org or lock_data.get("org"),
|
||
repo or lock_data.get("repo"),
|
||
)
|
||
auth = _auth(h)
|
||
gate = _assess_issue_duplicate_gate(
|
||
issue_number,
|
||
h=h,
|
||
o=o,
|
||
r=r,
|
||
auth=auth,
|
||
locked_branch=locked_branch,
|
||
phase=phase,
|
||
)
|
||
if gate.get("block"):
|
||
return gate
|
||
return None
|
||
|
||
|
||
def _branch_entry_commit_sha(branch: dict | str) -> str | None:
|
||
"""Best-effort head SHA for a Gitea branch entry (None when absent)."""
|
||
if not isinstance(branch, dict):
|
||
return None
|
||
commit = branch.get("commit")
|
||
if isinstance(commit, dict):
|
||
sha = commit.get("id") or commit.get("sha")
|
||
if sha:
|
||
return str(sha)
|
||
sha = branch.get("commit_sha")
|
||
return str(sha) if sha else None
|
||
|
||
|
||
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]
|
||
resolved_host = host or profile["host"]
|
||
resolved_org = org or profile["org"]
|
||
resolved_repo = repo or profile["repo"]
|
||
_enforce_remote_repo_guard(
|
||
remote,
|
||
resolved_org,
|
||
resolved_repo,
|
||
org_explicit=org is not None,
|
||
repo_explicit=repo is not None,
|
||
)
|
||
return (resolved_host, resolved_org, resolved_repo)
|
||
|
||
|
||
def _enforce_remote_repo_guard(
|
||
remote: str,
|
||
resolved_org: str,
|
||
resolved_repo: str,
|
||
*,
|
||
org_explicit: bool,
|
||
repo_explicit: bool,
|
||
) -> None:
|
||
"""Fail closed on a remote/repo mismatch vs. the local git remote (#530).
|
||
|
||
Best-effort: bypassed under pytest unless ``GITEA_FORCE_REMOTE_REPO_CHECK`` is
|
||
set, so the unit suite (which calls tools with bare remotes against mocked APIs)
|
||
is unaffected. In production it protects every read/lookup/mutation tool because
|
||
they all resolve targets through :func:`_resolve`.
|
||
"""
|
||
if "pytest" in sys.modules and not os.environ.get(
|
||
"GITEA_FORCE_REMOTE_REPO_CHECK"
|
||
):
|
||
return
|
||
local_remote_url = _local_git_remote_url(remote)
|
||
assessment = remote_repo_guard.assess_remote_repo_match(
|
||
remote=remote,
|
||
resolved_org=resolved_org,
|
||
resolved_repo=resolved_repo,
|
||
local_remote_url=local_remote_url,
|
||
org_explicit=org_explicit,
|
||
repo_explicit=repo_explicit,
|
||
)
|
||
if assessment["block"]:
|
||
raise RuntimeError(remote_repo_guard.format_remote_repo_guard_error(assessment))
|
||
|
||
|
||
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,
|
||
worktree_path: str | 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.
|
||
worktree_path: Optional path to verify branches-only guard.
|
||
|
||
Returns:
|
||
dict with 'number' of the created issue ('url' only with the reveal opt-in).
|
||
"""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||
"create_issue"
|
||
)
|
||
if not ok:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"number": None,
|
||
"reasons": block_reasons,
|
||
}
|
||
blocked = _namespace_mutation_block(
|
||
"create_issue", number=None, remote=remote)
|
||
if blocked:
|
||
return blocked
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("create_issue"),
|
||
number=None,
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
|
||
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"))
|
||
|
||
|
||
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
|
||
"""Fetch all OPEN pull requests for a repo (used for stacked-base proof, #484)."""
|
||
try:
|
||
return api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth) or []
|
||
except Exception as exc: # fail closed: no proof of an open dependency PR
|
||
raise RuntimeError(
|
||
f"Could not list open pull requests to verify stacked base: {exc}"
|
||
)
|
||
|
||
|
||
@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,
|
||
stacked_base_branch: str | None = None,
|
||
stacked_base_pr: int | 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).
|
||
stacked_base_branch: Opt-in. Declare a non-master base branch for a
|
||
*stacked* PR (a PR based on another unmerged PR's branch). Normal work
|
||
leaves this ``None`` and stays master-equivalent. When set, the
|
||
worktree may be base-equivalent to this branch instead of
|
||
master/main/dev, and the approved base is recorded on the lock (#484).
|
||
stacked_base_pr: Required when ``stacked_base_branch`` is set. The number
|
||
of the OPEN pull request that owns the stacked base branch. The lock
|
||
fails closed unless this open PR exists and owns that branch, so
|
||
arbitrary or stale branches cannot be used as stacked bases.
|
||
"""
|
||
# 1. Enforce branch name includes issue number
|
||
expected_pattern = f"issue-{issue_number}"
|
||
if expected_pattern not in branch_name:
|
||
raise ValueError(
|
||
f"Branch name '{branch_name}' must contain locked issue pattern '{expected_pattern}' (fail closed)"
|
||
)
|
||
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("lock_issue"))
|
||
if blocked:
|
||
return blocked
|
||
|
||
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
||
worktree_path, PROJECT_ROOT
|
||
)
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
|
||
_load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number),
|
||
issue_number=issue_number,
|
||
branch_name=branch_name,
|
||
worktree_path=resolved_worktree,
|
||
operation_type=AUTHOR_ISSUE_WORK_LEASE,
|
||
)
|
||
if active_lease_block:
|
||
raise RuntimeError(active_lease_block)
|
||
|
||
# ── Stacked-PR base declaration (opt-in, #484) ──
|
||
# Normal work leaves stacked_base_branch None → master-equivalent path.
|
||
# A declared stacked base must be proven to own an OPEN PR before it can
|
||
# anchor base-equivalence; this never bypasses the lock.
|
||
stacked_extra_bases: tuple[str, ...] = ()
|
||
stacked_approved: dict | None = None
|
||
if stacked_base_branch:
|
||
stacked_assessment = stacked_pr_support.assess_stacked_base_declaration(
|
||
stacked_base_branch=stacked_base_branch,
|
||
stacked_base_pr=stacked_base_pr,
|
||
open_prs=_list_open_pulls(h, o, r, _auth(h)),
|
||
)
|
||
if stacked_assessment["block"]:
|
||
raise RuntimeError(
|
||
"; ".join(stacked_assessment["reasons"]) + " (fail closed)"
|
||
)
|
||
stacked_approved = stacked_assessment["approved"]
|
||
stacked_extra_bases = (stacked_approved["branch"],)
|
||
|
||
if stacked_extra_bases:
|
||
git_state = issue_lock_worktree.read_worktree_git_state(
|
||
resolved_worktree, extra_bases=stacked_extra_bases
|
||
)
|
||
else:
|
||
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
|
||
verify_preflight_purity(remote, worktree_path=resolved_worktree, task="lock_issue")
|
||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||
worktree_path=resolved_worktree,
|
||
current_branch=git_state.get("current_branch"),
|
||
porcelain_status=git_state.get("porcelain_status") or "",
|
||
base_equivalent=git_state.get("base_equivalent"),
|
||
inspected_git_root=git_state.get("inspected_git_root"),
|
||
base_branch=git_state.get("base_branch"),
|
||
)
|
||
if lock_assessment["block"]:
|
||
raise RuntimeError(
|
||
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
|
||
)
|
||
|
||
auth = _auth(h)
|
||
duplicate_gate = _assess_issue_duplicate_gate(
|
||
issue_number,
|
||
h=h,
|
||
o=o,
|
||
r=r,
|
||
auth=auth,
|
||
locked_branch=branch_name,
|
||
phase=issue_work_duplicate_gate.PHASE_LOCK,
|
||
)
|
||
if duplicate_gate.get("block"):
|
||
raise ValueError("; ".join(duplicate_gate.get("reasons") or [
|
||
f"duplicate work gate blocked issue #{issue_number} (fail closed)"
|
||
]))
|
||
|
||
branch_url = f"{repo_api_url(h, o, r)}/branches"
|
||
try:
|
||
branches = api_get_all(branch_url, auth)
|
||
except Exception as e:
|
||
raise RuntimeError(f"Could not list branches to verify issue lock: {e}")
|
||
existing_branch_entries = [
|
||
{
|
||
"name": _branch_entry_name(branch),
|
||
"commit_sha": _branch_entry_commit_sha(branch),
|
||
}
|
||
for branch in branches
|
||
]
|
||
adoption = issue_lock_adoption.assess_own_branch_adoption(
|
||
issue_number=issue_number,
|
||
requested_branch=branch_name,
|
||
existing_branches=existing_branch_entries,
|
||
)
|
||
if adoption["block"]:
|
||
competing = ", ".join(adoption["competing_branches"])
|
||
raise ValueError(
|
||
f"Issue #{issue_number} already has matching branch '{competing}' "
|
||
"that is not the requested branch (fail closed)"
|
||
)
|
||
|
||
work_lease = _build_author_issue_work_lease(
|
||
issue_number=issue_number,
|
||
branch_name=branch_name,
|
||
worktree_path=resolved_worktree,
|
||
host=h,
|
||
)
|
||
data = {
|
||
"issue_number": issue_number,
|
||
"branch_name": branch_name,
|
||
"remote": remote,
|
||
"org": o,
|
||
"repo": r,
|
||
"worktree_path": resolved_worktree,
|
||
"work_lease": work_lease,
|
||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||
tool="gitea_lock_issue",
|
||
claimant=work_lease.get("claimant"),
|
||
),
|
||
}
|
||
if stacked_approved:
|
||
data["approved_stacked_base"] = stacked_approved
|
||
|
||
lock_file_path = _save_issue_lock(data)
|
||
lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
|
||
freshness = issue_lock_store.assess_lock_freshness(lock_record)
|
||
competing = [
|
||
entry
|
||
for entry in issue_lock_store.list_live_locks()
|
||
if entry.get("issue_number") != issue_number
|
||
]
|
||
lock_proof = issue_lock_store.format_lock_proof(
|
||
lock_record,
|
||
freshness=freshness,
|
||
competing_live_locks=competing,
|
||
released=False,
|
||
)
|
||
|
||
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
|
||
git_state.get("porcelain_status") or ""
|
||
)
|
||
result = {
|
||
"success": True,
|
||
"message": (
|
||
f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
|
||
f"from worktree '{resolved_worktree}' (fail-closed check complete)."
|
||
),
|
||
"issue_number": issue_number,
|
||
"branch_name": branch_name,
|
||
"worktree_path": resolved_worktree,
|
||
"work_lease": work_lease,
|
||
"lock_file_path": lock_file_path,
|
||
"lock_freshness": freshness,
|
||
"lock_proof": lock_proof,
|
||
}
|
||
if stacked_approved:
|
||
result["approved_stacked_base"] = stacked_approved
|
||
result["message"] = (
|
||
f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
|
||
f"as a STACKED PR on base '{stacked_approved['branch']}' "
|
||
f"(open PR #{stacked_approved['pr_number']}); fail-closed check complete."
|
||
)
|
||
if adoption["adopt"]:
|
||
result["adoption"] = issue_lock_adoption.build_adoption_proof(
|
||
issue_number=issue_number,
|
||
branch_name=branch_name,
|
||
assessment=adoption,
|
||
open_pr_checked=True,
|
||
competing_lock_checked=True,
|
||
lock_file_path=lock_file_path,
|
||
lock_file_status="written",
|
||
)
|
||
result["message"] = (
|
||
f"Adopted existing branch '{branch_name}' and locked issue "
|
||
f"#{issue_number} for recovery (fail-closed check complete)."
|
||
)
|
||
else:
|
||
# #477 AC2: normal (no-adoption) lock responses carry explicit,
|
||
# adoption-free proof metadata so they stay clear and cannot be
|
||
# misread as claiming a branch was adopted.
|
||
result["adoption_check"] = issue_lock_adoption.build_non_adoption_lock_proof(
|
||
issue_number=issue_number,
|
||
branch_name=branch_name,
|
||
)
|
||
if agent_artifacts:
|
||
result["warnings"] = [
|
||
"Agent temp artifacts at repo root (delete before implementation): "
|
||
+ ", ".join(agent_artifacts)
|
||
]
|
||
return result
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_assess_work_issue_duplicate(
|
||
issue_number: int,
|
||
branch_name: str | None = None,
|
||
phase: str = issue_work_duplicate_gate.PHASE_LOCK,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only duplicate-work gate for author sessions before mutations (#400)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
gate = _assess_issue_duplicate_gate(
|
||
issue_number,
|
||
h=h,
|
||
o=o,
|
||
r=r,
|
||
auth=auth,
|
||
locked_branch=branch_name,
|
||
phase=phase,
|
||
)
|
||
return {"success": not gate.get("block"), **gate}
|
||
|
||
|
||
@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, worktree_path=worktree_path, task="create_pr")
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
|
||
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
||
lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head)
|
||
|
||
lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(
|
||
lock_data
|
||
)
|
||
if lock_provenance_check["block"]:
|
||
raise RuntimeError(
|
||
issue_lock_provenance.format_lock_provenance_error(lock_provenance_check)
|
||
)
|
||
|
||
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)"
|
||
)
|
||
|
||
ownership = issue_lock_store.verify_lock_for_mutation(
|
||
lock_data,
|
||
issue_number=locked_issue,
|
||
branch_name=head,
|
||
worktree_path=worktree_path,
|
||
)
|
||
if ownership["block"]:
|
||
raise ValueError(ownership["reasons"][0])
|
||
|
||
# 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)"
|
||
)
|
||
|
||
# ── Stacked-PR base validation (#484) ──
|
||
# Normal base branches (master/main/dev) pass unchanged. A non-base target is
|
||
# allowed only when it matches the lock's approved stacked base, that base still
|
||
# has an open PR, and the body documents the stack. This never bypasses the lock.
|
||
base_open_prs = (
|
||
[]
|
||
if stacked_pr_support.is_base_branch(base)
|
||
else _list_open_pulls(h, o, r, _auth(h))
|
||
)
|
||
base_check = stacked_pr_support.assess_create_pr_base(
|
||
base=base,
|
||
approved_stacked_base=lock_data.get("approved_stacked_base"),
|
||
body=body,
|
||
open_prs=base_open_prs,
|
||
)
|
||
if base_check["block"]:
|
||
raise ValueError("; ".join(base_check["reasons"]) + " (fail closed)")
|
||
|
||
duplicate_block = _enforce_locked_issue_duplicate_recheck(
|
||
remote,
|
||
issue_work_duplicate_gate.PHASE_CREATE_PR,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
if duplicate_block:
|
||
return _duplicate_gate_block_response(
|
||
duplicate_block,
|
||
number=None,
|
||
issue_number=locked_issue,
|
||
branch_name=locked_branch,
|
||
)
|
||
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/pulls"
|
||
payload = {"title": title, "body": body, "head": head, "base": base}
|
||
meta = {"title": title, "head": head, "base": base}
|
||
try:
|
||
data = api_request("POST", url, auth, payload)
|
||
except Exception as exc:
|
||
_audit("create_pr", host=h, remote=remote, org=o, repo=r,
|
||
result=gitea_audit.FAILED, reason=_redact(str(exc)),
|
||
target_branch=head, request_metadata=meta,
|
||
mutation_task="create_pr")
|
||
raise
|
||
_audit("create_pr", host=h, remote=remote, org=o, repo=r,
|
||
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
|
||
target_branch=head, request_metadata=meta,
|
||
mutation_task="create_pr")
|
||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
||
|
||
|
||
def _format_list_pr_entry(pr: dict) -> dict:
|
||
head_obj = pr.get("head") or {}
|
||
base_obj = pr.get("base") or {}
|
||
entry = {
|
||
"number": pr["number"],
|
||
"title": pr["title"],
|
||
"state": pr["state"],
|
||
"head": head_obj.get("ref") if isinstance(head_obj, dict) else head_obj,
|
||
"base": base_obj.get("ref") if isinstance(base_obj, dict) else base_obj,
|
||
"mergeable": pr.get("mergeable"),
|
||
"updated_at": pr.get("updated_at"),
|
||
"head_sha": head_obj.get("sha") if isinstance(head_obj, dict) else None,
|
||
"author": (pr.get("user") or {}).get("login"),
|
||
"labels": [
|
||
label["name"]
|
||
for label in pr.get("labels", [])
|
||
if isinstance(label, dict) and label.get("name")
|
||
],
|
||
"body": pr.get("body") or "",
|
||
}
|
||
return _with_optional_url(entry, pr.get("html_url"))
|
||
|
||
|
||
def _list_prs_pagination_envelope(
|
||
prs: list[dict],
|
||
*,
|
||
page: int,
|
||
per_page: int,
|
||
pages_fetched: int,
|
||
is_final_page: bool,
|
||
has_more: bool,
|
||
next_page: int | None,
|
||
inventory_complete: bool,
|
||
) -> dict:
|
||
return {
|
||
"prs": prs,
|
||
"pagination": {
|
||
"page": page,
|
||
"per_page": per_page,
|
||
"returned_count": len(prs),
|
||
"has_more": has_more,
|
||
"next_page": next_page,
|
||
"is_final_page": is_final_page,
|
||
"pages_fetched": pages_fetched,
|
||
"inventory_complete": inventory_complete,
|
||
"total_count": len(prs) if inventory_complete else None,
|
||
},
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_list_prs(
|
||
state: str = "open",
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
page: int | None = None,
|
||
per_page: int = 50,
|
||
) -> dict:
|
||
"""List pull requests on a Gitea repository.
|
||
|
||
Args:
|
||
state: State filter — 'open', 'closed', or 'all' (default: 'open').
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
page: Optional 1-based page for explicit single-page fetch. When
|
||
omitted, all pages are traversed and ``inventory_complete`` is set.
|
||
per_page: Page size (1–50; Gitea caps at 50).
|
||
|
||
Returns:
|
||
Dict with ``prs`` (list of PR summaries) and ``pagination`` metadata
|
||
(``has_more``, ``next_page``, ``is_final_page``, ``inventory_complete``,
|
||
``pages_fetched``, ``total_count`` when complete). Each PR entry carries
|
||
``number``, ``title``, ``state``, ``head``, ``base``, ``mergeable``,
|
||
``updated_at`` ('url' only with the reveal opt-in).
|
||
"""
|
||
allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool(
|
||
"list_prs"
|
||
)
|
||
if not allowed:
|
||
raise RuntimeError("; ".join(block_reasons))
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
|
||
|
||
if page is not None:
|
||
raw_page, pagination = api_fetch_page(
|
||
url, auth, page=page, limit=per_page
|
||
)
|
||
prs = [_format_list_pr_entry(pr) for pr in raw_page]
|
||
return _list_prs_pagination_envelope(
|
||
prs,
|
||
page=pagination["page"],
|
||
per_page=pagination["per_page"],
|
||
pages_fetched=1,
|
||
is_final_page=pagination["is_final_page"],
|
||
has_more=pagination["has_more"],
|
||
next_page=pagination["next_page"],
|
||
inventory_complete=False,
|
||
)
|
||
|
||
all_raw: list[dict] = []
|
||
current_page = 1
|
||
pages_fetched = 0
|
||
last_meta: dict = {}
|
||
while pages_fetched < 100:
|
||
raw_page, last_meta = api_fetch_page(
|
||
url, auth, page=current_page, limit=per_page
|
||
)
|
||
pages_fetched += 1
|
||
all_raw.extend(raw_page)
|
||
if last_meta["is_final_page"]:
|
||
break
|
||
current_page += 1
|
||
|
||
prs = [_format_list_pr_entry(pr) for pr in all_raw]
|
||
return _list_prs_pagination_envelope(
|
||
prs,
|
||
page=1,
|
||
per_page=per_page,
|
||
pages_fetched=pages_fetched,
|
||
is_final_page=bool(last_meta.get("is_final_page")),
|
||
has_more=False,
|
||
next_page=None,
|
||
inventory_complete=bool(last_meta.get("is_final_page")),
|
||
)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_view_pr(
|
||
pr_number: int,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Get details of a single pull request.
|
||
|
||
Args:
|
||
pr_number: The pull request index/number.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with PR details including 'updated_at', 'merged_at',
|
||
'merge_commit_sha', 'closed_at' (when present) to support
|
||
live-state reconciliation and staleness detection against
|
||
prior reports or handoffs.
|
||
"""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
|
||
pr = api_request("GET", url, auth)
|
||
result = {
|
||
"number": pr["number"],
|
||
"title": pr["title"],
|
||
"body": pr.get("body", ""),
|
||
"state": pr["state"],
|
||
"head": pr["head"]["ref"],
|
||
"base": pr["base"]["ref"],
|
||
"mergeable": pr.get("mergeable"),
|
||
"user": pr.get("user", {}).get("login", ""),
|
||
"updated_at": pr.get("updated_at"),
|
||
"merged_at": pr.get("merged_at"),
|
||
"merge_commit_sha": pr.get("merge_commit_sha"),
|
||
"closed_at": pr.get("closed_at"),
|
||
}
|
||
return _with_optional_url(result, pr.get("html_url"))
|
||
|
||
|
||
# Actions whose eligibility this tool can evaluate.
|
||
_ELIGIBILITY_ACTIONS = ("review", "approve", "request_changes", "merge")
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_check_pr_eligibility(
|
||
pr_number: int,
|
||
action: str,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: is the current identity/profile eligible to perform *action*
|
||
on a PR?
|
||
|
||
Evaluates eligibility only — it NEVER reviews, approves, requests changes,
|
||
merges, or mutates anything. It inspects the authenticated identity
|
||
(via the /user endpoint), the active runtime profile metadata
|
||
(``get_profile``), and the target PR (author, state, head SHA,
|
||
mergeability), then returns a decision with clear reasons.
|
||
|
||
Fail-closed rules:
|
||
- Unknown action or unknown remote → not eligible.
|
||
- Profile has no configured allowed operations, or the action is not in
|
||
the profile's allowed operations (or is forbidden) → not eligible.
|
||
- Authenticated identity cannot be determined → not eligible.
|
||
- Authenticated user equals the PR author → not eligible to ``approve`` or
|
||
``merge``.
|
||
- PR is not open → not eligible.
|
||
- For ``merge``, PR must be reported mergeable.
|
||
|
||
Never returns the token, Authorization header, or any credential material.
|
||
|
||
Args:
|
||
pr_number: Target PR number.
|
||
action: One of 'review', 'approve', 'request_changes', 'merge'.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with 'eligible' (bool), the inputs inspected, and 'reasons';
|
||
when the block is a missing profile permission, also a structured
|
||
'permission_report' (#142).
|
||
"""
|
||
action = (action or "").strip().lower()
|
||
if action in ("review", "approve", "request_changes", "merge"):
|
||
allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool(
|
||
"check_pr_eligibility"
|
||
)
|
||
if not allowed:
|
||
return {
|
||
"eligible": False,
|
||
"requested_action": action,
|
||
"reasons": block_reasons,
|
||
"terminal_mode": True,
|
||
}
|
||
profile = get_profile()
|
||
result = {
|
||
"eligible": False,
|
||
"requested_action": action,
|
||
"authenticated_user": None,
|
||
"profile_name": profile["profile_name"],
|
||
"allowed_operations": profile["allowed_operations"],
|
||
"pr_author": None,
|
||
"pr_number": pr_number,
|
||
"pr_state": None,
|
||
"head_sha": None,
|
||
"mergeable": None,
|
||
"remote": remote if remote in REMOTES else None,
|
||
"reasons": [],
|
||
}
|
||
reasons = result["reasons"]
|
||
|
||
if action not in _ELIGIBILITY_ACTIONS:
|
||
reasons.append(
|
||
f"unknown action '{action}'; expected one of {list(_ELIGIBILITY_ACTIONS)}"
|
||
)
|
||
return result
|
||
|
||
if remote not in REMOTES:
|
||
reasons.append(f"unknown remote '{remote}'")
|
||
return result
|
||
|
||
# Profile capability check (metadata only; not enforcement of the action).
|
||
# Both the action and the profile lists are normalized before comparison
|
||
# (#106), so legacy spellings ("merge") and canonical namespaced ops
|
||
# ("gitea.pr.merge") always match each other and never cross services.
|
||
allowed = profile["allowed_operations"]
|
||
forbidden = profile["forbidden_operations"]
|
||
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
||
if not op_ok:
|
||
if op_reason == "no-allowed-operations":
|
||
reasons.append(
|
||
"profile has no configured allowed operations (fail closed)")
|
||
elif op_reason == "forbidden":
|
||
reasons.append(f"profile forbids '{action}'")
|
||
elif op_reason == "invalid-forbidden-entry":
|
||
reasons.append(
|
||
"profile has an unrecognized forbidden operation entry "
|
||
"(fail closed)")
|
||
else:
|
||
reasons.append(f"profile is not allowed to {action}")
|
||
|
||
# Stop here immediately if the action implies reviewer role and the
|
||
# profile doesn't permit it. No identity/PR API calls on this path;
|
||
# the structured #142 report is built config-only (no network).
|
||
if action in ("review", "approve", "request_changes", "merge"):
|
||
result["eligible"] = False
|
||
missing_perm = (
|
||
f"gitea.pr.{action}"
|
||
if action in ("approve", "request_changes", "merge", "review")
|
||
else f"gitea.{action}")
|
||
result["missing_permission"] = missing_perm
|
||
result["active_profile"] = profile["profile_name"]
|
||
result["active_identity"] = None
|
||
result["permission_report"] = _permission_block_report(
|
||
missing_perm)
|
||
|
||
req_profile = None
|
||
if action in ("approve", "request_changes", "review"):
|
||
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
|
||
elif action == "merge":
|
||
req_profile = "A profile with reviewer role permissions and explicit merge permission"
|
||
result["required_profile"] = req_profile
|
||
|
||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||
if switching_supported:
|
||
result["fixable_by_profile_switch"] = True
|
||
result["requires_different_namespace"] = False
|
||
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
|
||
else:
|
||
result["fixable_by_profile_switch"] = False
|
||
result["requires_different_namespace"] = True
|
||
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
||
|
||
result["safe_next_step"] = safe_step
|
||
return result
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
|
||
# Authenticated identity (read-only). Fail soft; never leak error/secret.
|
||
try:
|
||
auth = _auth(h)
|
||
except Exception:
|
||
auth = None
|
||
auth_user = None
|
||
if auth:
|
||
try:
|
||
who = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
|
||
auth_user = (who or {}).get("login")
|
||
except Exception:
|
||
auth_user = None
|
||
result["authenticated_user"] = auth_user
|
||
if not auth_user:
|
||
reasons.append("authenticated identity could not be determined")
|
||
|
||
# PR facts (read-only GET; no mutation).
|
||
pr_author = None
|
||
pr_state = None
|
||
if auth:
|
||
try:
|
||
pr = api_request(
|
||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
|
||
)
|
||
pr_author = (pr or {}).get("user", {}).get("login")
|
||
pr_state = (pr or {}).get("state")
|
||
result["head_sha"] = ((pr or {}).get("head") or {}).get("sha")
|
||
result["mergeable"] = (pr or {}).get("mergeable")
|
||
except Exception:
|
||
reasons.append("PR details could not be retrieved")
|
||
else:
|
||
reasons.append("PR details could not be retrieved (no credentials)")
|
||
result["pr_author"] = pr_author
|
||
result["pr_state"] = pr_state
|
||
|
||
# PR must be open to act on.
|
||
if pr_state is None:
|
||
reasons.append("PR state unknown")
|
||
elif pr_state != "open":
|
||
reasons.append(f"PR is not open (state={pr_state})")
|
||
|
||
# Self-author must not approve or merge their own PR.
|
||
if auth_user and pr_author and auth_user == pr_author and action in ("approve", "merge"):
|
||
reasons.append("authenticated user is PR author")
|
||
|
||
# Merge needs a positive mergeability signal.
|
||
if action == "merge":
|
||
if result["mergeable"] is False:
|
||
reasons.append("PR is not mergeable")
|
||
elif result["mergeable"] is None:
|
||
reasons.append("PR mergeability unknown")
|
||
|
||
result["eligible"] = len(reasons) == 0
|
||
if result["eligible"]:
|
||
reasons.append("all eligibility checks passed")
|
||
|
||
# Add enhanced error clarity details (#131)
|
||
auth_user = result["authenticated_user"]
|
||
pr_author = result["pr_author"]
|
||
profile_name = result["profile_name"]
|
||
is_self = bool(auth_user and pr_author and auth_user == pr_author)
|
||
|
||
result["active_identity"] = auth_user
|
||
result["active_profile"] = profile_name
|
||
result["self_author"] = is_self
|
||
|
||
# Determine missing permission
|
||
missing_perm = None
|
||
if not op_ok:
|
||
missing_perm = (
|
||
f"gitea.pr.{action}"
|
||
if action in ("approve", "request_changes", "merge", "review")
|
||
else f"gitea.{action}")
|
||
|
||
result["missing_permission"] = missing_perm
|
||
if missing_perm:
|
||
# Structured denial guidance (#142) — read-only, never widens.
|
||
result["permission_report"] = _permission_block_report(
|
||
missing_perm, identity=auth_user)
|
||
|
||
# Determine required profile
|
||
req_profile = None
|
||
if action in ("approve", "request_changes", "review"):
|
||
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
|
||
elif action == "merge":
|
||
req_profile = "A profile with reviewer role permissions and explicit merge permission"
|
||
result["required_profile"] = req_profile
|
||
|
||
# Determine required identity
|
||
req_identity = None
|
||
if is_self:
|
||
req_identity = f"Any Gitea user other than PR author '{pr_author}'"
|
||
result["required_identity"] = req_identity
|
||
|
||
# Determine if fixable by profile switch vs requires different namespace
|
||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||
fixable_by_switch = False
|
||
req_different_ns = False
|
||
|
||
if not op_ok:
|
||
config = gitea_config.load_config()
|
||
has_capable_profile = False
|
||
if config:
|
||
for name, p in (config.get("profiles") or {}).items():
|
||
p_allowed = p.get("allowed_operations", [])
|
||
p_forbidden = p.get("forbidden_operations", [])
|
||
ok, _ = gitea_config.check_operation(action, p_allowed, p_forbidden)
|
||
if ok:
|
||
has_capable_profile = True
|
||
break
|
||
if switching_supported and has_capable_profile:
|
||
fixable_by_switch = True
|
||
else:
|
||
req_different_ns = True
|
||
|
||
result["fixable_by_profile_switch"] = fixable_by_switch
|
||
result["requires_different_namespace"] = req_different_ns
|
||
|
||
# Determine safe next step
|
||
safe_step = "Ready."
|
||
if not result["eligible"]:
|
||
if is_self:
|
||
safe_step = "Self-review/self-merge is forbidden. Ask a different operator/reviewer to review and merge this PR."
|
||
elif not auth_user:
|
||
safe_step = f"Ask the operator to configure valid credentials/token for profile '{profile_name}'."
|
||
elif not op_ok:
|
||
if fixable_by_switch:
|
||
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
|
||
else:
|
||
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
||
elif result["pr_state"] != "open":
|
||
safe_step = "No action can be taken on a closed/merged PR."
|
||
elif action == "merge":
|
||
if result["mergeable"] is False:
|
||
safe_step = "Address conflicts in the PR branches first, or wait for CI checks to pass before merging."
|
||
elif result["mergeable"] is None:
|
||
safe_step = "Wait for Gitea to finish calculating mergeability or trigger a re-check."
|
||
|
||
result["safe_next_step"] = safe_step
|
||
return result
|
||
|
||
|
||
# Review actions this gated tool can perform, mapped to (eligibility action,
|
||
# Gitea review *event*). The eligibility action is fed to
|
||
# ``gitea_check_pr_eligibility`` (#14) so every mutation reuses the same
|
||
# identity/profile/author gates. Note: 'merge' is deliberately absent — merge
|
||
# belongs to a separate tool/issue and is never performed here.
|
||
_REVIEW_ACTIONS = {
|
||
# 'comment' posts review findings without an approval/rejection state.
|
||
# #14 names this eligibility category 'review'.
|
||
"comment": ("review", "COMMENT"),
|
||
# Gitea ReviewStateType uses APPROVED, not APPROVE — wrong event leaves PENDING (#244).
|
||
"approve": ("approve", "APPROVED"),
|
||
"request_changes": ("request_changes", "REQUEST_CHANGES"),
|
||
}
|
||
|
||
_TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
||
|
||
# Durable across MCP daemon process pools (#559), but never under /tmp (#211):
|
||
# host-global temp files are spoofable. Persistence uses the user-private
|
||
# cache directory from mcp_session_state (mode 0o700 / files 0o600), keyed by
|
||
# remote + profile identity with TTL.
|
||
_REVIEW_DECISION_LOCK: dict | None = None
|
||
|
||
|
||
def _decision_lock_binding(lock: dict | None = None) -> dict:
|
||
"""Resolve key fields for durable decision-lock storage."""
|
||
profile = get_profile()
|
||
profile_name = (profile.get("profile_name") or "").strip()
|
||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||
stored_lock = ((lock or {}).get("session_profile_lock") or "").strip()
|
||
session_lock = env_lock or stored_lock or profile_name
|
||
remote = ((lock or {}).get("remote") or "").strip() or None
|
||
org = ((lock or {}).get("org") or (lock or {}).get("ready_org") or "").strip() or None
|
||
repo = ((lock or {}).get("repo") or (lock or {}).get("ready_repo") or "").strip() or None
|
||
return {
|
||
"session_profile": profile_name,
|
||
"session_profile_lock": session_lock,
|
||
"profile_identity": mcp_session_state.current_profile_identity(
|
||
profile_name=profile_name,
|
||
session_profile_lock=session_lock,
|
||
),
|
||
"remote": remote,
|
||
"org": org,
|
||
"repo": repo,
|
||
}
|
||
|
||
|
||
def _load_review_decision_lock():
|
||
"""Load decision lock from memory, falling back to durable session state."""
|
||
global _REVIEW_DECISION_LOCK
|
||
if _REVIEW_DECISION_LOCK is not None:
|
||
return _REVIEW_DECISION_LOCK
|
||
binding = _decision_lock_binding()
|
||
# Profile-keyed durable file; remote/org/repo validated from payload (#559).
|
||
durable = mcp_session_state.load_state(
|
||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||
profile_identity=binding.get("profile_identity"),
|
||
)
|
||
if durable is not None:
|
||
_REVIEW_DECISION_LOCK = dict(durable)
|
||
return _REVIEW_DECISION_LOCK
|
||
|
||
|
||
def _save_review_decision_lock(data):
|
||
"""Persist decision lock to memory + durable shared state (#559)."""
|
||
global _REVIEW_DECISION_LOCK
|
||
if data is None:
|
||
binding = _decision_lock_binding(_REVIEW_DECISION_LOCK)
|
||
mcp_session_state.clear_state(
|
||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||
profile_identity=binding.get("profile_identity"),
|
||
)
|
||
_REVIEW_DECISION_LOCK = None
|
||
return
|
||
payload = dict(data)
|
||
binding = _decision_lock_binding(payload)
|
||
payload.setdefault("session_pid", os.getpid())
|
||
payload["writer_pid"] = os.getpid()
|
||
payload["session_profile"] = binding["session_profile"] or payload.get(
|
||
"session_profile"
|
||
)
|
||
payload["session_profile_lock"] = binding["session_profile_lock"]
|
||
payload["profile_identity"] = binding["profile_identity"]
|
||
if binding.get("remote") and not payload.get("remote"):
|
||
payload["remote"] = binding["remote"]
|
||
persisted = mcp_session_state.save_state(
|
||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||
payload=payload,
|
||
remote=payload.get("remote"),
|
||
org=payload.get("org") or payload.get("ready_org"),
|
||
repo=payload.get("repo") or payload.get("ready_repo"),
|
||
profile_identity=payload.get("profile_identity"),
|
||
)
|
||
_REVIEW_DECISION_LOCK = dict(persisted or payload)
|
||
|
||
|
||
def _review_decision_session_reasons(lock: dict | None) -> list[str]:
|
||
"""Reject locks that do not belong to this MCP session identity (#559).
|
||
|
||
Different daemon PIDs in the same IDE session pool are allowed when the
|
||
profile identity and remote match. Spoofed/stale locks still fail closed.
|
||
"""
|
||
if lock is None:
|
||
return []
|
||
reasons = []
|
||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||
stored_lock = (lock.get("session_profile_lock") or lock.get("profile_identity") or "").strip()
|
||
if env_lock and stored_lock and env_lock != stored_lock:
|
||
reasons.append(
|
||
"review decision lock session profile lock mismatch (fail closed)"
|
||
)
|
||
# TTL / identity checks from durable envelope fields.
|
||
for reason in mcp_session_state.identity_match_reasons(
|
||
lock,
|
||
remote=lock.get("remote"),
|
||
org=lock.get("org") or lock.get("ready_org"),
|
||
repo=lock.get("repo") or lock.get("ready_repo"),
|
||
profile_identity=env_lock or stored_lock,
|
||
):
|
||
if "profile identity mismatch" in reason or "expired" in reason or "future" in reason or "missing recorded_at" in reason:
|
||
reasons.append(reason)
|
||
return reasons
|
||
|
||
|
||
def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True):
|
||
"""Seed read-only-until-ready state for reviewer PR review tasks."""
|
||
if task != "review_pr":
|
||
return
|
||
if not force:
|
||
lock = _load_review_decision_lock()
|
||
if lock is not None:
|
||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||
same_remote = lock.get("remote") == remote
|
||
same_profile = (not env_lock or not stored_lock or env_lock == stored_lock)
|
||
if same_remote and same_profile and not _review_decision_session_reasons(lock):
|
||
return
|
||
review_workflow_load.clear_review_workflow_load()
|
||
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
|
||
)
|
||
reviewer_pr_lease.clear_session_lease()
|
||
_save_review_decision_lock({
|
||
"task": task,
|
||
"remote": remote,
|
||
"session_pid": os.getpid(),
|
||
"session_profile": profile_name,
|
||
"session_profile_lock": session_lock,
|
||
"profile_identity": mcp_session_state.current_profile_identity(
|
||
profile_name=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 _review_workflow_load_gate_reasons() -> list[str]:
|
||
"""Fail closed when canonical review workflow was not loaded (#389)."""
|
||
return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT)
|
||
|
||
|
||
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 = list(_review_workflow_load_gate_reasons())
|
||
if reasons:
|
||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||
return reasons
|
||
lock = _load_review_decision_lock()
|
||
if lock is None:
|
||
reasons.append(
|
||
"review decision lock missing; call gitea_resolve_task_capability "
|
||
"for review_pr before live review mutations (fail closed)"
|
||
)
|
||
return reasons
|
||
|
||
reasons.extend(_review_decision_session_reasons(lock))
|
||
if reasons:
|
||
return reasons
|
||
|
||
if remote in REMOTES:
|
||
_, org, repo = _resolve(remote, None, org, repo)
|
||
|
||
if not final_review_decision_ready:
|
||
reasons.append(
|
||
"final_review_decision_ready must be true; validation-phase live "
|
||
"review mutations are forbidden — use gitea_dry_run_pr_review "
|
||
"instead (fail closed)"
|
||
)
|
||
return reasons
|
||
|
||
if not lock.get("final_review_decision_ready"):
|
||
reasons.append(
|
||
"final review decision not marked ready; call "
|
||
"gitea_mark_final_review_decision after validation completes "
|
||
"(fail closed)"
|
||
)
|
||
return reasons
|
||
|
||
if lock.get("ready_pr_number") != pr_number:
|
||
reasons.append(
|
||
f"ready PR #{lock.get('ready_pr_number')} does not match "
|
||
f"requested PR #{pr_number} (fail closed)"
|
||
)
|
||
if lock.get("ready_action") != action:
|
||
reasons.append(
|
||
f"ready action '{lock.get('ready_action')}' does not match "
|
||
f"requested action '{action}' (fail closed)"
|
||
)
|
||
if lock.get("ready_remote") != remote:
|
||
reasons.append(
|
||
f"ready remote '{lock.get('ready_remote')}' does not match "
|
||
f"requested remote '{remote}' (fail closed)"
|
||
)
|
||
if lock.get("ready_org") != org:
|
||
reasons.append(
|
||
f"ready org '{lock.get('ready_org')}' does not match "
|
||
f"requested org '{org}' (fail closed)"
|
||
)
|
||
if lock.get("ready_repo") != repo:
|
||
reasons.append(
|
||
f"ready repo '{lock.get('ready_repo')}' does not match "
|
||
f"requested repo '{repo}' (fail closed)"
|
||
)
|
||
|
||
prior = list(lock.get("live_mutations") or [])
|
||
if prior and not lock.get("correction_authorized"):
|
||
reasons.append(
|
||
"live review mutation already recorded in this run; only one live "
|
||
"review mutation is allowed unless "
|
||
"gitea_authorize_review_correction was invoked (fail closed)"
|
||
)
|
||
elif (
|
||
action in _TERMINAL_REVIEW_ACTIONS
|
||
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
|
||
and not lock.get("correction_authorized")
|
||
):
|
||
reasons.append(
|
||
"terminal review decision already submitted on this PR in this "
|
||
"run; blocked unless an operator-approved correction was "
|
||
"authorized (fail closed)"
|
||
)
|
||
|
||
return reasons
|
||
|
||
|
||
def record_live_review_mutation(pr_number: int, action: str, review_id: int | None = None):
|
||
lock = _load_review_decision_lock() or {}
|
||
mutations = list(lock.get("live_mutations") or [])
|
||
mutations.append({
|
||
"pr_number": pr_number,
|
||
"action": action,
|
||
"review_id": review_id,
|
||
"review_state": action,
|
||
})
|
||
lock["live_mutations"] = mutations
|
||
if lock.get("correction_authorized"):
|
||
lock["correction_authorized"] = False
|
||
lock["correction_reason"] = None
|
||
_save_review_decision_lock(lock)
|
||
|
||
|
||
def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[str]:
|
||
"""Session hard-stop after a terminal live review mutation (#332).
|
||
|
||
After a terminal verdict is consumed in this run, the only permitted
|
||
continuation is the merge sequence for the same PR that was approved.
|
||
A REQUEST_CHANGES (or an approval of a different PR) blocks every
|
||
further review/mark-ready/merge mutation. An operator-approved
|
||
correction (#211) re-opens the review path only — never a cross-PR
|
||
merge.
|
||
|
||
*operation* is one of 'merge', 'mark_ready', or 'review'.
|
||
Returns [] when the operation may proceed.
|
||
"""
|
||
lock = _load_review_decision_lock()
|
||
if lock is None:
|
||
return []
|
||
terminals = [
|
||
m for m in (lock.get("live_mutations") or [])
|
||
if m.get("action") in _TERMINAL_REVIEW_ACTIONS
|
||
]
|
||
if not terminals:
|
||
return []
|
||
last = terminals[-1]
|
||
if (
|
||
operation == "merge"
|
||
and last.get("action") == "approve"
|
||
and last.get("pr_number") == pr_number
|
||
):
|
||
return []
|
||
if operation in ("mark_ready", "review") and lock.get("correction_authorized"):
|
||
return []
|
||
if last.get("action") == "approve":
|
||
guidance = (
|
||
f"only the merge sequence for approved PR "
|
||
f"#{last.get('pr_number')} may continue"
|
||
)
|
||
else:
|
||
guidance = "the session must stop and produce a final report"
|
||
return [
|
||
"terminal review mutation already consumed in this run "
|
||
f"({last.get('action')} on PR #{last.get('pr_number')}); {guidance} "
|
||
"(fail closed, #332)"
|
||
]
|
||
|
||
|
||
# Patterns scrubbed from any surfaced error text so a credential can never leak.
|
||
_SECRET_PREFIXES = ("token ", "Basic ")
|
||
|
||
|
||
def _redact(text: str) -> str:
|
||
"""Strip anything that looks like an Authorization credential or raw URL from *text*.
|
||
|
||
Errors raised by ``api_request`` echo the server response body, not the
|
||
request headers, so a token should never appear — this is defence in depth
|
||
so a future change can't leak ``token …`` / ``Basic …`` material into a
|
||
tool result or log line.
|
||
"""
|
||
if not text:
|
||
return text
|
||
out = text
|
||
for prefix in _SECRET_PREFIXES:
|
||
idx = 0
|
||
while True:
|
||
i = out.find(prefix, idx)
|
||
if i == -1:
|
||
break
|
||
j = i + len(prefix)
|
||
while j < len(out) and not out[j].isspace():
|
||
j += 1
|
||
out = out[:i] + prefix + "[REDACTED]" + out[j:]
|
||
idx = i + len(prefix) + len("[REDACTED]")
|
||
# Redact raw URLs, query secrets, hostnames, etc.
|
||
import gitea_audit
|
||
return gitea_audit.redact_urls(out)
|
||
|
||
|
||
# Review states that carry a submitted verdict. Gitea also emits PENDING
|
||
# (draft) and REQUEST_REVIEW (re-review ask); neither is a verdict and
|
||
# neither may drive the blocking/approval summaries.
|
||
_VERDICT_STATES = ("APPROVED", "REQUEST_CHANGES", "COMMENT")
|
||
|
||
# Terminal review events that must be visible after live submission (#244).
|
||
_SUBMIT_VISIBLE_EVENTS = frozenset({"APPROVED", "REQUEST_CHANGES"})
|
||
|
||
|
||
def _latest_review_state_for_reviewer(raw_reviews: list, reviewer: str) -> str | None:
|
||
"""Return the latest non-COMMENT verdict for *reviewer*, or None."""
|
||
ordered = sorted(
|
||
raw_reviews or [],
|
||
key=lambda rv: ((rv.get("submitted_at") or ""), rv.get("id") or 0),
|
||
)
|
||
latest = None
|
||
for rv in ordered:
|
||
state = (rv.get("state") or "").upper()
|
||
login = (rv.get("user") or {}).get("login", "")
|
||
if login != reviewer or state not in _VERDICT_STATES or state == "COMMENT":
|
||
continue
|
||
latest = state
|
||
return latest
|
||
|
||
|
||
def _submit_pending_pull_review(
|
||
h: str,
|
||
o: str,
|
||
r: str,
|
||
pr_number: int,
|
||
review_id: int,
|
||
event: str,
|
||
body: str,
|
||
auth,
|
||
) -> dict | None:
|
||
"""Submit a PENDING draft review via Gitea's pending-review endpoint."""
|
||
submit_url = (
|
||
f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews/{review_id}"
|
||
)
|
||
return api_request("POST", submit_url, auth, {"body": body, "event": event})
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_get_pr_review_feedback(
|
||
pr_number: int,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: discover formal PR review feedback through MCP (#167).
|
||
|
||
Formal review verdicts (APPROVED / REQUEST_CHANGES / COMMENT) live on
|
||
Gitea's review endpoints, not in the issue-comment thread. This tool is
|
||
the MCP-native way to read them, so an LLM never has to infer review
|
||
state from issue comments. It never submits reviews and never mutates
|
||
anything; the profile must allow ``gitea.read`` (fail closed otherwise,
|
||
with a structured #142 permission report and no API call made).
|
||
|
||
Endpoints: ``GET /repos/{owner}/{repo}/pulls/{n}`` (current head SHA)
|
||
and ``GET /repos/{owner}/{repo}/pulls/{n}/reviews``.
|
||
|
||
Normal output is LLM-safe: review bodies pass through credential
|
||
redaction and no endpoint URLs appear. Set GITEA_MCP_REVEAL_ENDPOINTS=1
|
||
(admin/debug opt-in) to include each review's web link.
|
||
|
||
Args:
|
||
pr_number: The pull request index/number.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with 'success', 'pr_number', 'pr_state', 'current_head_sha',
|
||
'reviews' (each: reviewer, verdict, body, submitted_at,
|
||
reviewed_head_sha, dismissed, stale),
|
||
'latest_review_state_by_reviewer' (latest non-COMMENT verdict per
|
||
reviewer; PENDING drafts never count),
|
||
'has_blocking_change_requests' (a reviewer's latest verdict is an
|
||
undismissed REQUEST_CHANGES), 'approval_visible',
|
||
'latest_reviewed_head_sha' (head at the most recent
|
||
APPROVED/REQUEST_CHANGES review; COMMENT-only reviews never
|
||
advance it), 'review_feedback_stale' (that verdict feedback
|
||
predates the current head SHA), and
|
||
'author_pushed_after_request_changes' (new commits landed after a
|
||
blocking REQUEST_CHANGES). On a permission block: 'success' False,
|
||
'feedback_not_attempted' True, 'reasons', and 'permission_report' —
|
||
deliberately distinct from a successful "no reviews yet" result.
|
||
"""
|
||
reasons = _profile_operation_gate("gitea.read")
|
||
if reasons:
|
||
return {
|
||
"success": False,
|
||
"pr_number": pr_number,
|
||
"feedback_not_attempted": True,
|
||
"reasons": reasons,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
|
||
pr = api_request("GET", base, auth) or {}
|
||
raw_reviews = api_request("GET", f"{base}/reviews", auth) or []
|
||
current_head = (pr.get("head") or {}).get("sha")
|
||
reveal = _reveal_endpoints()
|
||
|
||
ordered = sorted(
|
||
raw_reviews,
|
||
key=lambda rv: ((rv.get("submitted_at") or ""), rv.get("id") or 0),
|
||
)
|
||
reviews = []
|
||
latest_by_reviewer = {}
|
||
latest_reviewed_head = None
|
||
for rv in ordered:
|
||
state = (rv.get("state") or "").upper()
|
||
reviewer = (rv.get("user") or {}).get("login", "")
|
||
commit_id = rv.get("commit_id")
|
||
entry = {
|
||
"reviewer": reviewer,
|
||
"verdict": state,
|
||
"body": _redact(rv.get("body") or ""),
|
||
"submitted_at": rv.get("submitted_at"),
|
||
"reviewed_head_sha": commit_id,
|
||
"dismissed": bool(rv.get("dismissed")),
|
||
"stale": bool(rv.get("stale")) or bool(
|
||
commit_id and current_head and commit_id != current_head),
|
||
}
|
||
if reveal:
|
||
entry["url"] = rv.get("html_url")
|
||
reviews.append(entry)
|
||
# COMMENT reviews never advance the reviewed-head marker or the
|
||
# per-reviewer verdict — otherwise a drive-by comment on the
|
||
# current head would mask the staleness of an older undismissed
|
||
# REQUEST_CHANGES.
|
||
if state in _VERDICT_STATES and state != "COMMENT":
|
||
latest_reviewed_head = commit_id or latest_reviewed_head
|
||
if reviewer:
|
||
latest_by_reviewer[reviewer] = entry
|
||
|
||
blocking = [
|
||
e for e in latest_by_reviewer.values()
|
||
if e["verdict"] == "REQUEST_CHANGES" and not e["dismissed"]
|
||
]
|
||
approvals = [
|
||
e for e in latest_by_reviewer.values()
|
||
if e["verdict"] == "APPROVED" and not e["dismissed"]
|
||
]
|
||
approval_head = merge_approval_gate.assess_merge_approval_head(
|
||
current_head_sha=current_head,
|
||
latest_by_reviewer=latest_by_reviewer,
|
||
)
|
||
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),
|
||
"approval_at_current_head": approval_head["approval_at_current_head"],
|
||
"latest_approved_head_sha": approval_head["latest_approved_head_sha"],
|
||
"stale_approval_block_reason": approval_head["stale_approval_block_reason"],
|
||
"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 _fetch_pr_lease_comments_safe(
|
||
pr_number: int,
|
||
*,
|
||
remote: str,
|
||
host: str | None,
|
||
org: str | None,
|
||
repo: str | None,
|
||
limit: int = 100,
|
||
require_open: bool = False,
|
||
) -> dict:
|
||
"""Fetch PR thread comments with structured fail-closed errors (#519)."""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
resolved_repo = f"{o}/{r}"
|
||
pr_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
|
||
try:
|
||
pr = api_request("GET", pr_url, auth)
|
||
except RuntimeError as exc:
|
||
return {
|
||
"success": False,
|
||
"comments": [],
|
||
"reasons": [
|
||
"PR lookup failed before conflict-fix push assessment "
|
||
f"(pr_number={pr_number}, repo={resolved_repo}, remote={remote}): "
|
||
f"{_redact(str(exc))}"
|
||
],
|
||
"pr_lookup": "failed",
|
||
"resolved_repo": resolved_repo,
|
||
"remote": remote,
|
||
"pr_number": pr_number,
|
||
}
|
||
pr_state = (pr.get("state") or "").strip().lower()
|
||
if require_open and pr_state != "open":
|
||
return {
|
||
"success": False,
|
||
"comments": [],
|
||
"reasons": [
|
||
f"PR #{pr_number} on {resolved_repo} is not open "
|
||
f"(state={pr_state or 'unknown'})"
|
||
],
|
||
"pr_lookup": "not_open",
|
||
"resolved_repo": resolved_repo,
|
||
"remote": remote,
|
||
"pr_number": pr_number,
|
||
"head_sha": (pr.get("head") or {}).get("sha"),
|
||
}
|
||
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
try:
|
||
comments = api_request("GET", api, auth)
|
||
except RuntimeError as exc:
|
||
return {
|
||
"success": False,
|
||
"comments": [],
|
||
"reasons": [
|
||
"PR comment fetch failed during conflict-fix push assessment "
|
||
f"(pr_number={pr_number}, issue_index={pr_number}, "
|
||
f"repo={resolved_repo}, remote={remote}): "
|
||
f"{_redact(str(exc))}"
|
||
],
|
||
"pr_lookup": "ok",
|
||
"resolved_repo": resolved_repo,
|
||
"remote": remote,
|
||
"pr_number": pr_number,
|
||
"head_sha": (pr.get("head") or {}).get("sha"),
|
||
}
|
||
if not isinstance(comments, list):
|
||
comments = []
|
||
return {
|
||
"success": True,
|
||
"comments": list(comments[:limit]),
|
||
"reasons": [],
|
||
"pr_lookup": "ok",
|
||
"resolved_repo": resolved_repo,
|
||
"remote": remote,
|
||
"pr_number": pr_number,
|
||
"head_sha": (pr.get("head") or {}).get("sha"),
|
||
}
|
||
|
||
|
||
def _list_pr_lease_comments(
|
||
pr_number: int,
|
||
*,
|
||
remote: str,
|
||
host: str | None,
|
||
org: str | None,
|
||
repo: str | None,
|
||
limit: int = 100,
|
||
) -> list[dict]:
|
||
"""Fetch PR/issue thread comments used for reviewer/conflict-fix leases.
|
||
|
||
Intentionally does **not** pre-fetch the PR via GET /pulls/{n}. Shared
|
||
callers (merge approval feedback, reviewer lease gates) depend on a single
|
||
comments GET so mock sequences and fail-open #485 non-list handling stay
|
||
stable. Structured PR-lookup failures belong only to
|
||
:func:`_fetch_pr_lease_comments_safe` used by conflict-fix push assessment
|
||
(#519).
|
||
"""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
comments = api_request("GET", api, auth)
|
||
# Fail safe to no lease comments when the API returns a non-list payload
|
||
# (e.g. an error object such as an HTTP 401 body): lease state can only be
|
||
# proven from real comment entries, never inferred from an error shape (#485).
|
||
if not isinstance(comments, list):
|
||
return []
|
||
return list(comments[:limit])
|
||
|
||
|
||
def _pr_work_lease_reviewer_block(
|
||
*,
|
||
pr_number: int,
|
||
reviewed_head_sha: str | None,
|
||
live_head_sha: str | None,
|
||
mutation: str,
|
||
remote: str,
|
||
host: str | None,
|
||
org: str | None,
|
||
repo: str | None,
|
||
) -> dict:
|
||
comments = _list_pr_lease_comments(
|
||
pr_number,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
return pr_work_lease.assess_reviewer_mutation_blocked(
|
||
pr_number=pr_number,
|
||
comments=comments,
|
||
reviewed_head_sha=reviewed_head_sha,
|
||
live_head_sha=live_head_sha,
|
||
mutation=mutation,
|
||
)
|
||
|
||
|
||
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,
|
||
worktree_path: str | None = None,
|
||
) -> dict:
|
||
"""Shared gate chain for live submit and dry-run review tools."""
|
||
_verify_role_mutation_workspace(
|
||
remote, worktree_path=worktree_path, task="review_pr"
|
||
)
|
||
action = (action or "").strip().lower()
|
||
workflow_blockers = _review_workflow_load_gate_reasons() if live else []
|
||
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 workflow_blockers:
|
||
reasons.extend(workflow_blockers)
|
||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||
return result
|
||
|
||
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
|
||
|
||
if live:
|
||
reasons.extend(_reviewer_pr_lease_gate(
|
||
pr_number=pr_number,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
mutation=action,
|
||
live_head_sha=result.get("head_sha"),
|
||
pinned_head_sha=expected_head_sha,
|
||
))
|
||
if reasons:
|
||
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 live and not pinned_sha:
|
||
reasons.append(
|
||
"reviewed head SHA required before live review mutation (fail closed, #399)"
|
||
)
|
||
return result
|
||
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
|
||
|
||
lease_block = _pr_work_lease_reviewer_block(
|
||
pr_number=pr_number,
|
||
reviewed_head_sha=pinned_sha,
|
||
live_head_sha=actual_sha,
|
||
mutation=action,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
if lease_block.get("block"):
|
||
reasons.extend(lease_block.get("reasons") or [])
|
||
result["pr_work_lease"] = lease_block
|
||
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
|
||
workflow_blockers = _review_workflow_load_gate_reasons()
|
||
if workflow_blockers:
|
||
return {
|
||
"marked_ready": False,
|
||
"reasons": workflow_blockers + (
|
||
review_workflow_load.recovery_handoff_without_replay()),
|
||
}
|
||
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
|
||
if hard_stop:
|
||
return {"marked_ready": False, "reasons": hard_stop}
|
||
if lock.get("live_mutations") and not lock.get("correction_authorized"):
|
||
return {
|
||
"marked_ready": False,
|
||
"reasons": [
|
||
"cannot mark final decision after a live review mutation was "
|
||
"already recorded in this run unless an operator-approved "
|
||
"correction was authorized"
|
||
],
|
||
}
|
||
if action not in _REVIEW_ACTIONS:
|
||
return {
|
||
"marked_ready": False,
|
||
"reasons": [
|
||
f"unknown review action '{action}'; expected one of "
|
||
f"{sorted(_REVIEW_ACTIONS)}"
|
||
],
|
||
}
|
||
if not (expected_head_sha or "").strip():
|
||
return {
|
||
"marked_ready": False,
|
||
"reasons": [
|
||
"expected_head_sha required before marking final review "
|
||
"decision (fail closed, #399)"
|
||
],
|
||
}
|
||
elig = gitea_check_pr_eligibility(
|
||
pr_number=pr_number,
|
||
action="review",
|
||
remote=remote,
|
||
host=None,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
live_head = elig.get("head_sha")
|
||
lease_block = _pr_work_lease_reviewer_block(
|
||
pr_number=pr_number,
|
||
reviewed_head_sha=expected_head_sha,
|
||
live_head_sha=live_head,
|
||
mutation="mark_ready",
|
||
remote=remote,
|
||
host=None,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
if lease_block.get("block"):
|
||
return {
|
||
"marked_ready": False,
|
||
"reasons": lease_block.get("reasons") or [],
|
||
"pr_work_lease": lease_block,
|
||
}
|
||
if action == "request_changes":
|
||
# Duplicate request-changes suppression (#332): an unresolved
|
||
# REQUEST_CHANGES at the current head must not be duplicated.
|
||
feedback = gitea_get_pr_review_feedback(
|
||
pr_number, remote=remote, org=org, repo=repo)
|
||
if not feedback.get("success"):
|
||
return {
|
||
"marked_ready": False,
|
||
"reasons": [
|
||
"could not verify existing request-changes state for "
|
||
f"PR #{pr_number}; refusing to submit a possibly "
|
||
"duplicate REQUEST_CHANGES (fail closed, #332)"
|
||
],
|
||
}
|
||
if (
|
||
feedback.get("has_blocking_change_requests")
|
||
and not feedback.get("review_feedback_stale")
|
||
):
|
||
return {
|
||
"marked_ready": False,
|
||
"reasons": [
|
||
f"PR #{pr_number} already has an unresolved "
|
||
"REQUEST_CHANGES at the current head SHA; do not "
|
||
"duplicate the request-changes review (fail closed, "
|
||
"#332)"
|
||
],
|
||
}
|
||
lock["final_review_decision_ready"] = True
|
||
lock["ready_pr_number"] = pr_number
|
||
lock["ready_action"] = action
|
||
lock["ready_expected_head_sha"] = expected_head_sha
|
||
lock["ready_remote"] = remote
|
||
lock["ready_org"] = org
|
||
lock["ready_repo"] = repo
|
||
_save_review_decision_lock(lock)
|
||
return {
|
||
"marked_ready": True,
|
||
"pr_number": pr_number,
|
||
"action": action,
|
||
"expected_head_sha": expected_head_sha,
|
||
"remote": remote,
|
||
"org": org,
|
||
"repo": repo,
|
||
"final_review_decision_ready": True,
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_authorize_review_correction(
|
||
prior_review_id: int,
|
||
prior_review_state: str,
|
||
reason: str,
|
||
operator_authorized: bool = False,
|
||
) -> dict:
|
||
"""Authorize one operator-approved correction after a mistaken live review."""
|
||
reason = (reason or "").strip()
|
||
prior_review_state = (prior_review_state or "").strip().lower()
|
||
lock = _load_review_decision_lock()
|
||
if lock is None:
|
||
return {"authorized": False, "reasons": ["review decision lock missing"]}
|
||
session_reasons = _review_decision_session_reasons(lock)
|
||
if session_reasons:
|
||
return {"authorized": False, "reasons": session_reasons}
|
||
if not operator_authorized:
|
||
return {
|
||
"authorized": False,
|
||
"reasons": [
|
||
"operator authorization is required for review correction "
|
||
"(fail closed)"
|
||
],
|
||
}
|
||
prior = list(lock.get("live_mutations") or [])
|
||
if not prior:
|
||
return {
|
||
"authorized": False,
|
||
"reasons": ["no prior live review mutation to correct"],
|
||
}
|
||
last_mutation = prior[-1]
|
||
last_review_id = last_mutation.get("review_id")
|
||
last_review_state = last_mutation.get("action")
|
||
reasons = []
|
||
if last_review_id is not None and last_review_id != prior_review_id:
|
||
reasons.append(
|
||
f"prior review ID '{prior_review_id}' does not match last "
|
||
f"recorded review ID '{last_review_id}' (fail closed)"
|
||
)
|
||
if last_review_state != prior_review_state:
|
||
reasons.append(
|
||
f"prior review state '{prior_review_state}' does not match last "
|
||
f"recorded review state '{last_review_state}' (fail closed)"
|
||
)
|
||
if not reason:
|
||
reasons.append("correction reason is required")
|
||
if reasons:
|
||
return {"authorized": False, "reasons": reasons}
|
||
lock["correction_authorized"] = True
|
||
lock["correction_reason"] = reason
|
||
_save_review_decision_lock(lock)
|
||
return {"authorized": True, "correction_reason": reason, "reasons": []}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_dry_run_pr_review(
|
||
pr_number: int,
|
||
action: str,
|
||
body: str = "",
|
||
expected_head_sha: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
worktree_path: 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,
|
||
worktree_path=worktree_path,
|
||
)
|
||
|
||
|
||
@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,
|
||
worktree_path: str | None = None,
|
||
) -> 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,
|
||
worktree_path=worktree_path,
|
||
)
|
||
|
||
|
||
@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.")
|
||
|
||
closing = payload.get("state") == "closed"
|
||
verify_preflight_purity(remote, task="close_pr" if closing else None)
|
||
|
||
# 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.
|
||
if closing:
|
||
gate_reasons = _profile_operation_gate("gitea.pr.close")
|
||
if gate_reasons:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"pr_number": pr_number,
|
||
"requested_state": "closed",
|
||
"required_permission": "gitea.pr.close",
|
||
"reasons": gate_reasons,
|
||
"permission_report": _permission_block_report("gitea.pr.close"),
|
||
}
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}"
|
||
|
||
request_metadata = {"fields": sorted(payload)}
|
||
if closing:
|
||
request_metadata["required_permission"] = "gitea.pr.close"
|
||
with _audited("close_pr" if closing else "edit_pr",
|
||
host=h, remote=remote, org=o, repo=r,
|
||
pr_number=pr_number, request_metadata=request_metadata):
|
||
data = api_request("PATCH", url, auth, payload)
|
||
|
||
cleanup_status = None
|
||
if state == "closed":
|
||
cleanup = cleanup_in_progress_for_pr(data, remote, host, org, repo)
|
||
cleanup_status = cleanup.get("cleanup_status")
|
||
if isinstance(cleanup_status, dict):
|
||
for issue_num, st in cleanup_status.items():
|
||
if st == "released":
|
||
try:
|
||
comment_url = f"{repo_api_url(h, o, r)}/issues/{issue_num}/comments"
|
||
api_request("POST", comment_url, auth, {"body": f"Tracker cleanup: removed `status:in-progress` from this issue because linked PR #{pr_number} was closed."})
|
||
except Exception:
|
||
pass
|
||
|
||
result = {
|
||
"success": True,
|
||
"number": data["number"],
|
||
"title": data["title"],
|
||
"body": data.get("body", ""),
|
||
"state": data["state"],
|
||
"cleanup_status": cleanup_status,
|
||
}
|
||
return _with_optional_url(result, data.get("html_url"))
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_get_file(
|
||
filepath: str,
|
||
ref: str = "main",
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Retrieve metadata and content of a file from a Gitea repository.
|
||
|
||
Args:
|
||
filepath: The path to the file in the repository (e.g. 'README.md' or 'src/main.py').
|
||
ref: The branch, tag, or commit hash to retrieve the file from (default: 'main').
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict containing 'name', 'path', 'sha', 'size', 'encoding', and 'content' (base64).
|
||
"""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
import urllib.parse
|
||
encoded_path = urllib.parse.quote(filepath, safe="")
|
||
url = f"{repo_api_url(h, o, r)}/contents/{encoded_path}?ref={ref}"
|
||
data = api_request("GET", url, auth)
|
||
return {
|
||
"name": data.get("name", ""),
|
||
"path": data.get("path", ""),
|
||
"sha": data.get("sha", ""),
|
||
"size": data.get("size", 0),
|
||
"encoding": data.get("encoding", ""),
|
||
"content": data.get("content", ""),
|
||
}
|
||
|
||
|
||
def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[dict]]:
|
||
import base64
|
||
import json
|
||
import os
|
||
|
||
processed_files = []
|
||
source_proofs = []
|
||
|
||
lock_data = issue_lock_store.read_session_issue_lock() or {}
|
||
|
||
locked_worktree = lock_data.get("worktree_path")
|
||
if locked_worktree:
|
||
locked_worktree = os.path.realpath(locked_worktree)
|
||
|
||
for f in files:
|
||
f_copy = dict(f)
|
||
path = f_copy.get("path", "")
|
||
|
||
content_keys = [k for k in ["content", "content_plain", "workspace_path", "local_path"] if k in f_copy]
|
||
if len(content_keys) > 1:
|
||
raise ValueError(
|
||
f"Multiple content sources specified for file '{path}'; provide exactly one of "
|
||
f"'content', 'content_plain', 'workspace_path', or 'local_path' (fail closed)"
|
||
)
|
||
|
||
source = "none"
|
||
if "content_plain" in f_copy:
|
||
content_plain = f_copy.pop("content_plain")
|
||
if content_plain is not None:
|
||
content_bytes = content_plain.encode("utf-8")
|
||
f_copy["content"] = base64.b64encode(content_bytes).decode("utf-8")
|
||
source = "inline_plain"
|
||
elif "workspace_path" in f_copy:
|
||
workspace_path = f_copy.pop("workspace_path")
|
||
if not locked_worktree:
|
||
raise RuntimeError(
|
||
f"Issue lock is missing or does not define a worktree path. "
|
||
f"Cannot resolve workspace_path '{workspace_path}' (fail closed)."
|
||
)
|
||
target_path = os.path.realpath(os.path.abspath(os.path.join(locked_worktree, workspace_path)))
|
||
|
||
is_inside = target_path == locked_worktree or target_path.startswith(os.path.join(locked_worktree, ""))
|
||
is_tmp = target_path.startswith(os.path.realpath("/tmp") + os.sep) or target_path.startswith("/tmp" + os.sep)
|
||
|
||
if not (is_inside or is_tmp):
|
||
raise ValueError(
|
||
f"workspace_path '{workspace_path}' resolves to '{target_path}' which falls outside "
|
||
f"of locked worktree '{locked_worktree}' (fail closed)"
|
||
)
|
||
|
||
if not os.path.exists(target_path):
|
||
raise FileNotFoundError(f"File not found: {target_path} (fail closed)")
|
||
|
||
with open(target_path, "rb") as fh:
|
||
file_bytes = fh.read()
|
||
f_copy["content"] = base64.b64encode(file_bytes).decode("utf-8")
|
||
source = "workspace_path"
|
||
elif "local_path" in f_copy:
|
||
local_path = f_copy.pop("local_path")
|
||
if not locked_worktree:
|
||
raise RuntimeError(
|
||
f"Issue lock is missing or does not define a worktree path. "
|
||
f"Cannot resolve local_path '{local_path}' (fail closed)."
|
||
)
|
||
target_path = os.path.realpath(os.path.abspath(local_path))
|
||
|
||
is_inside = target_path == locked_worktree or target_path.startswith(os.path.join(locked_worktree, ""))
|
||
is_tmp = target_path.startswith(os.path.realpath("/tmp") + os.sep) or target_path.startswith("/tmp" + os.sep)
|
||
|
||
if not (is_inside or is_tmp):
|
||
raise ValueError(
|
||
f"local_path '{local_path}' resolves to '{target_path}' which falls outside "
|
||
f"of locked worktree '{locked_worktree}' (fail closed)"
|
||
)
|
||
|
||
if not os.path.exists(target_path):
|
||
raise FileNotFoundError(f"File not found: {target_path} (fail closed)")
|
||
|
||
with open(target_path, "rb") as fh:
|
||
file_bytes = fh.read()
|
||
f_copy["content"] = base64.b64encode(file_bytes).decode("utf-8")
|
||
source = "local_path"
|
||
elif "content" in f_copy:
|
||
source = "inline_base64"
|
||
|
||
processed_files.append(f_copy)
|
||
source_proofs.append({
|
||
"path": path,
|
||
"source": source
|
||
})
|
||
|
||
return processed_files, source_proofs
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_commit_files(
|
||
files: list[dict],
|
||
message: str,
|
||
branch: str | None = None,
|
||
new_branch: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Commit changes to multiple files in a Gitea repository in a single atomic commit.
|
||
|
||
Args:
|
||
files: List of file operations. Each file dict must contain 'operation' ('create', 'update', 'delete', 'rename'), 'path', and one of content payload sources: 'content' (base64), 'content_plain', 'workspace_path', or 'local_path'.
|
||
message: The commit message.
|
||
branch: Optional existing branch to start/commit from.
|
||
new_branch: Optional new branch name to create for this commit.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with success status and commit/branch information.
|
||
"""
|
||
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||
"commit_files"
|
||
)
|
||
if not ok:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"commit": "",
|
||
"branch": "",
|
||
"reasons": block_reasons,
|
||
}
|
||
blocked = _namespace_mutation_block(
|
||
"commit_files", commit="", branch="", remote=remote
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("commit_files"),
|
||
commit="", branch="",
|
||
)
|
||
if blocked:
|
||
return blocked
|
||
|
||
duplicate_block = _enforce_locked_issue_duplicate_recheck(
|
||
remote,
|
||
issue_work_duplicate_gate.PHASE_COMMIT,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
if duplicate_block:
|
||
return _duplicate_gate_block_response(
|
||
duplicate_block,
|
||
commit="",
|
||
branch="",
|
||
)
|
||
|
||
verify_preflight_purity(remote, task="commit_files")
|
||
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/contents"
|
||
|
||
payload = {
|
||
"files": processed_files,
|
||
"message": message,
|
||
}
|
||
if branch is not None:
|
||
payload["branch"] = branch
|
||
if new_branch is not None:
|
||
payload["new_branch"] = new_branch
|
||
|
||
with _audited("commit_files", host=h, remote=remote, org=o, repo=r,
|
||
target_branch=(new_branch or branch),
|
||
request_metadata={"message": message,
|
||
"paths": [f.get("path") for f in processed_files],
|
||
"operations": [f.get("operation") for f in processed_files]}):
|
||
data = api_request("POST", url, auth, payload)
|
||
return {
|
||
"success": True,
|
||
"commit": data.get("commit", {}).get("sha", ""),
|
||
"branch": data.get("branch", {}).get("name", ""),
|
||
"content_source_proof": source_proofs,
|
||
}
|
||
|
||
|
||
# Merge methods supported by the Gitea merge API.
|
||
_MERGE_METHODS = ("merge", "squash", "rebase")
|
||
|
||
|
||
@mcp.tool()
|
||
@_audit_pr_result("merge_pr")
|
||
def gitea_merge_pr(
|
||
pr_number: int,
|
||
confirmation: str = "",
|
||
expected_head_sha: str | None = None,
|
||
expected_changed_files: list[str] | None = None,
|
||
do: str = "merge",
|
||
title: str | None = None,
|
||
message: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
worktree_path: 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.
|
||
worktree_path: Merger workspace path under ``branches/`` or clean
|
||
control checkout; defaults to ``GITEA_MERGER_WORKTREE``,
|
||
``GITEA_ACTIVE_WORKTREE``, or the MCP server process root. Ignores
|
||
foreign ``GITEA_AUTHOR_WORKTREE`` bindings (#510).
|
||
|
||
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_role_mutation_workspace(
|
||
remote, worktree_path=worktree_path, task="merge_pr"
|
||
)
|
||
workflow_blockers = _review_workflow_load_gate_reasons()
|
||
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"]
|
||
if workflow_blockers:
|
||
reasons.extend(workflow_blockers)
|
||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||
return result
|
||
|
||
# Gate 1 — valid merge method (no API call on a bad method).
|
||
if do not in _MERGE_METHODS:
|
||
reasons.append(
|
||
f"unknown merge method '{do}'; expected one of {list(_MERGE_METHODS)}"
|
||
)
|
||
return result
|
||
|
||
# Gate 2 — explicit confirmation (fail fast; zero API calls without it).
|
||
expected_confirmation = f"MERGE PR {pr_number}"
|
||
if (confirmation or "").strip() != expected_confirmation:
|
||
reasons.append(
|
||
f"explicit confirmation required: pass confirmation='{expected_confirmation}'"
|
||
)
|
||
return result
|
||
|
||
# Gate 2b — session hard-stop after a terminal review mutation (#332):
|
||
# merge may only continue for the same PR that was just approved.
|
||
# Local in-process check; zero API calls.
|
||
hard_stop = terminal_review_hard_stop_reasons(pr_number, "merge")
|
||
if hard_stop:
|
||
reasons.extend(hard_stop)
|
||
return result
|
||
|
||
# Gate 3 — reuse #14 eligibility (identity + profile + merge-allowed +
|
||
# author + self-merge block + open + mergeable/unknown fail-closed).
|
||
# Read-only GETs only.
|
||
elig = gitea_check_pr_eligibility(
|
||
pr_number=pr_number, action="merge", remote=remote,
|
||
host=host, org=org, repo=repo,
|
||
)
|
||
result["authenticated_user"] = elig.get("authenticated_user")
|
||
result["profile_name"] = elig.get("profile_name", result["profile_name"])
|
||
result["pr_author"] = elig.get("pr_author")
|
||
result["head_sha"] = elig.get("head_sha")
|
||
result["mergeable"] = elig.get("mergeable")
|
||
if not elig.get("eligible"):
|
||
reasons.append("eligibility check for 'merge' failed (fail closed)")
|
||
reasons.extend(elig.get("reasons", []))
|
||
if elig.get("permission_report"):
|
||
result["permission_report"] = elig["permission_report"]
|
||
return result
|
||
|
||
reasons.extend(_reviewer_pr_lease_gate(
|
||
pr_number=pr_number,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
mutation="merge",
|
||
live_head_sha=result.get("head_sha"),
|
||
pinned_head_sha=expected_head_sha,
|
||
))
|
||
if reasons:
|
||
return result
|
||
|
||
# Gate 4 — reviewed head SHA is mandatory and must match live PR head (#399).
|
||
actual_sha = result["head_sha"]
|
||
if not (expected_head_sha or "").strip():
|
||
reasons.append(
|
||
"expected_head_sha required before merge (fail closed, #399)"
|
||
)
|
||
return result
|
||
lease_block = _pr_work_lease_reviewer_block(
|
||
pr_number=pr_number,
|
||
reviewed_head_sha=expected_head_sha,
|
||
live_head_sha=actual_sha,
|
||
mutation="merge",
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
if lease_block.get("block"):
|
||
reasons.extend(lease_block.get("reasons") or [])
|
||
result["pr_work_lease"] = lease_block
|
||
return result
|
||
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["approval_at_current_head"] = feedback.get("approval_at_current_head")
|
||
result["latest_approved_head_sha"] = feedback.get("latest_approved_head_sha")
|
||
result["review_feedback_stale"] = feedback.get("review_feedback_stale")
|
||
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
|
||
if not feedback.get("approval_at_current_head"):
|
||
reasons.append(
|
||
feedback.get("stale_approval_block_reason")
|
||
or (
|
||
"approval does not apply to current PR head SHA "
|
||
"(fail closed); required next action: re-review PR at "
|
||
"current head before merge"
|
||
)
|
||
)
|
||
return result
|
||
|
||
# Gate 8 — in-process mutation authority (#199): the last check before
|
||
# the merge mutation, using the identity the eligibility gate proved.
|
||
# A profile/identity flip or side-channel override between preflight
|
||
# and merge fails closed here.
|
||
try:
|
||
verify_mutation_authority(remote, host, required_role="reviewer",
|
||
active_identity=auth_user)
|
||
except RuntimeError as e:
|
||
reasons.append(str(e))
|
||
return result
|
||
|
||
# All gates passed — perform the single merge mutation.
|
||
try:
|
||
auth = _auth(h)
|
||
merge_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/merge"
|
||
payload = {"Do": do}
|
||
if title:
|
||
payload["MergeTitleField"] = title
|
||
if message:
|
||
payload["MergeMessageField"] = message
|
||
api_request("POST", merge_url, auth, payload)
|
||
# Best-effort read-back of the merge commit SHA (redacted on error).
|
||
merged = None
|
||
try:
|
||
merged = api_request(
|
||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
|
||
)
|
||
result["merge_commit"] = (merged or {}).get("merged_commit_sha")
|
||
except Exception:
|
||
result["merge_commit"] = None
|
||
|
||
# Tracker cleanup (#98): never blocks the merge, and always surfaces an
|
||
# explicit cleanup_status once the merge mutation has happened. Cleanup
|
||
# needs the PR title/body/branch, which only the read-back payload
|
||
# carries here — so a failed read-back is an explicit skip, not a
|
||
# silent one.
|
||
if merged is None:
|
||
result["cleanup_status"] = "skipped (merge read-back failed)"
|
||
else:
|
||
try:
|
||
cleanup = cleanup_in_progress_for_pr(merged, remote, host, org, repo)
|
||
result["cleanup_status"] = cleanup.get("cleanup_status")
|
||
except Exception as cleanup_exc: # noqa: BLE001 — redact before surfacing
|
||
result["cleanup_status"] = (
|
||
f"skipped (cleanup error: {_redact(str(cleanup_exc))})"
|
||
)
|
||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||
reasons.append(f"merge failed: {_redact(str(exc))}")
|
||
return result
|
||
|
||
result["performed"] = True
|
||
result["merge_result"] = f"PR #{pr_number} merged via '{do}'."
|
||
reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'")
|
||
return result
|
||
|
||
|
||
def _local_git_remote_url(remote_name: str) -> str | None:
|
||
"""Best-effort local ``git remote get-url`` for trust-gate corroboration."""
|
||
try:
|
||
proc = subprocess.run(
|
||
["git", "remote", "get-url", remote_name],
|
||
capture_output=True,
|
||
text=True,
|
||
cwd=PROJECT_ROOT,
|
||
)
|
||
if proc.returncode != 0:
|
||
return None
|
||
url = (proc.stdout or "").strip()
|
||
return url or None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_review_pr(
|
||
pr_number: int,
|
||
event: str = "APPROVE",
|
||
body: str = "",
|
||
merge: bool = False,
|
||
merge_method: str = "merge",
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
final_review_decision_ready: bool = False,
|
||
) -> dict:
|
||
"""Submit a review on a Gitea pull request (Legacy wrapper).
|
||
|
||
This tool is a compatibility wrapper around the safe `gitea_submit_pr_review`.
|
||
It uses the same #14 eligibility gates.
|
||
Merging via this tool is no longer supported and will fail closed (see #16).
|
||
|
||
Args:
|
||
pr_number: The PR number to review.
|
||
event: Review type — 'APPROVE', 'COMMENT', or 'REQUEST_CHANGES'.
|
||
body: Review body text / comment.
|
||
merge: Merging is disabled; if True, the tool fails closed.
|
||
merge_method: Ignored.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with success status and message.
|
||
"""
|
||
if merge:
|
||
return {
|
||
"success": False,
|
||
"message": "merge=True is no longer supported in this tool (belongs to #16)."
|
||
}
|
||
|
||
if event not in ["APPROVE", "COMMENT", "REQUEST_CHANGES"]:
|
||
raise ValueError(f"Invalid review event: '{event}'. Choose from 'APPROVE', 'COMMENT', 'REQUEST_CHANGES'.")
|
||
|
||
# Map legacy event string to the action expected by gitea_submit_pr_review
|
||
event_map = {
|
||
"APPROVE": "approve",
|
||
"COMMENT": "comment",
|
||
"REQUEST_CHANGES": "request_changes"
|
||
}
|
||
action = event_map[event]
|
||
|
||
# --- Phase 1: Read-only PR queue inventory ---
|
||
profile = get_profile()
|
||
allowed = profile.get("allowed_operations") or []
|
||
forbidden = profile.get("forbidden_operations") or []
|
||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||
|
||
can_review, _ = gitea_config.check_operation("gitea.pr.review", allowed, forbidden)
|
||
can_approve, _ = gitea_config.check_operation("gitea.pr.approve", allowed, forbidden)
|
||
can_merge, _ = gitea_config.check_operation("gitea.pr.merge", allowed, forbidden)
|
||
can_read, _ = gitea_config.check_operation("gitea.read", allowed, forbidden)
|
||
|
||
is_reviewer = can_review or can_approve or can_merge
|
||
|
||
inventory_attempted = False
|
||
prs_found_count = 0
|
||
pr_details_list = []
|
||
inventory_msg = ""
|
||
inventory_trust_gate = None
|
||
prs: list = []
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = None
|
||
try:
|
||
auth = _auth(h)
|
||
except Exception:
|
||
pass
|
||
|
||
auth_user = None
|
||
if auth:
|
||
try:
|
||
who = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
|
||
auth_user = (who or {}).get("login")
|
||
except Exception:
|
||
pass
|
||
|
||
if can_read and auth:
|
||
inventory_attempted = True
|
||
try:
|
||
prs = gitea_list_prs(
|
||
state="open",
|
||
remote=remote,
|
||
host=h,
|
||
org=o,
|
||
repo=r,
|
||
)
|
||
prs_list = prs.get("prs") or []
|
||
prs_found_count = len(prs_list)
|
||
for pr in prs_list:
|
||
labels = list(pr.get("labels") or [])
|
||
body_text = pr.get("body") or ""
|
||
linked = []
|
||
# Simple extraction of linked issues from body
|
||
for match in re.findall(r'#(\d+)', body_text):
|
||
linked.append(f"#{match}")
|
||
|
||
pr_head_sha = pr.get("head_sha")
|
||
pr_author = pr.get("author")
|
||
|
||
# Determine review block status
|
||
req_non_author = False
|
||
if pr_author and auth_user and pr_author == auth_user:
|
||
req_non_author = True
|
||
|
||
pr_details_list.append({
|
||
"number": pr["number"],
|
||
"title": pr["title"],
|
||
"author": pr_author,
|
||
"base": pr["base"],
|
||
"head": pr["head"],
|
||
"mergeable": pr.get("mergeable"),
|
||
"linked_issues": list(set(linked)),
|
||
"labels": labels,
|
||
"head_sha": pr_head_sha,
|
||
"updated_at": pr.get("updated_at"),
|
||
"requires_non_author_reviewer": req_non_author
|
||
})
|
||
except Exception as e:
|
||
inventory_msg = f"PR inventory failed: {_redact(str(e))}"
|
||
else:
|
||
inventory_msg = "PR inventory not attempted"
|
||
|
||
# Build inventory report string
|
||
report_lines = []
|
||
if inventory_attempted:
|
||
report_lines.append(f"Repository: {o}/{r}")
|
||
if pr_details_list:
|
||
report_lines.append(f"Open PRs found: {prs_found_count}")
|
||
for item in pr_details_list:
|
||
status_str = "Review/merge blocked (Current profile is Author)"
|
||
if item["requires_non_author_reviewer"]:
|
||
status_str = "Review/merge blocked (Self-author). Requires a genuine non-author reviewer"
|
||
elif is_reviewer:
|
||
status_str = "Review/merge eligible under current profile"
|
||
|
||
report_lines.append(
|
||
f"- PR #{item['number']}: {item['title']}\n"
|
||
f" Author: {item['author']}\n"
|
||
f" Base/Head: {item['base']} / {item['head']}\n"
|
||
f" Head SHA: {item['head_sha']}\n"
|
||
f" Updated: {item.get('updated_at') or 'unknown'}\n"
|
||
f" Mergeability: {item['mergeable']}\n"
|
||
f" Linked Issues: {item['linked_issues']}\n"
|
||
f" Labels: {item['labels']}\n"
|
||
f" Status: {status_str}"
|
||
)
|
||
else:
|
||
if inventory_msg:
|
||
report_lines.append(inventory_msg)
|
||
else:
|
||
pagination = (
|
||
prs.get("pagination") if isinstance(prs, dict) else {}
|
||
) or {}
|
||
has_finality_metadata = bool(
|
||
pagination.get("inventory_complete")
|
||
or (
|
||
pagination.get("is_final_page")
|
||
and not pagination.get("has_more")
|
||
)
|
||
)
|
||
inventory_trust_gate = review_proofs.pr_inventory_trust_gate(
|
||
prs if inventory_attempted else None,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
state="open",
|
||
authenticated_profile=profile,
|
||
local_remote_url=_local_git_remote_url(remote),
|
||
has_finality_metadata=has_finality_metadata,
|
||
)
|
||
report_lines.extend(
|
||
review_proofs.format_pr_inventory_trust_gate_report(
|
||
inventory_trust_gate
|
||
)
|
||
)
|
||
if inventory_trust_gate.get("status") == "trusted_empty":
|
||
report_lines.append("Open PRs found: 0 (trusted_empty)")
|
||
else:
|
||
report_lines.append(
|
||
"Empty-queue claim blocked: "
|
||
"pr_inventory_trust_gate did not return trusted_empty"
|
||
)
|
||
else:
|
||
report_lines.append(inventory_msg)
|
||
|
||
inventory_report = "\n".join(report_lines)
|
||
|
||
# If the current profile is not a reviewer, or lacks necessary permissions for review action
|
||
if not is_reviewer:
|
||
# Determine the safe next step based on static vs dynamic profile switching support
|
||
if switching_supported:
|
||
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
|
||
else:
|
||
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
||
|
||
blocked_message = (
|
||
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
|
||
f"=== Review/Merge Blocked ===\n"
|
||
f"The active profile '{profile['profile_name']}' is an Author profile and does not have review or merge permissions.\n"
|
||
f"Safe next step: {safe_step}"
|
||
)
|
||
return {
|
||
"success": False,
|
||
"message": blocked_message
|
||
}
|
||
|
||
# --- Phase 2: Reviewer-only review/merge actions ---
|
||
# Since it is a reviewer profile, we proceed to submit the review
|
||
result = gitea_submit_pr_review(
|
||
pr_number=pr_number,
|
||
action=action,
|
||
body=body,
|
||
expected_head_sha=None,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
final_review_decision_ready=final_review_decision_ready
|
||
)
|
||
|
||
# Include the inventory report in the response message
|
||
if result.get("performed"):
|
||
msg = (
|
||
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
|
||
f"Successfully submitted review for PR #{pr_number} with event '{event}'."
|
||
)
|
||
return {"success": True, "message": msg}
|
||
else:
|
||
reasons = result.get("reasons", [])
|
||
msg = (
|
||
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
|
||
f"Review submission failed eligibility gates: {reasons}"
|
||
)
|
||
out = {"success": False, "message": msg}
|
||
if result.get("permission_report"):
|
||
out["permission_report"] = result["permission_report"]
|
||
return out
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_delete_branch(
|
||
branch: str,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Delete a remote branch from a Gitea repository.
|
||
|
||
Args:
|
||
branch: The remote branch name (e.g. 'feat/branch-name').
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with 'success' and 'message'; on a permission block,
|
||
'success'/'performed' False, 'reasons', and a structured
|
||
'permission_report' (#142) with no API call made.
|
||
"""
|
||
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
||
if gate_reasons:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"required_permission": "gitea.branch.delete",
|
||
"reasons": gate_reasons,
|
||
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||
}
|
||
|
||
audit_allowed, audit_reasons = (
|
||
audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
|
||
)
|
||
if not audit_allowed:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"required_permission": "gitea.branch.delete",
|
||
"reasons": audit_reasons,
|
||
"audit_phase": audit_reconciliation_mode.current_phase(),
|
||
}
|
||
|
||
verify_preflight_purity(remote, task="delete_branch")
|
||
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}"
|
||
request_metadata = {
|
||
"branch": branch,
|
||
"required_permission": "gitea.branch.delete",
|
||
}
|
||
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
|
||
target_branch=branch, request_metadata=request_metadata):
|
||
api_request("DELETE", url, auth)
|
||
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
||
|
||
|
||
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
|
||
import urllib.parse
|
||
|
||
encoded = urllib.parse.quote(branch, safe="")
|
||
url = f"{repo_api_url(h, o, r)}/branches/{encoded}"
|
||
try:
|
||
api_request("GET", url, auth)
|
||
return True
|
||
except Exception as exc:
|
||
message = str(exc).lower()
|
||
if "404" in message or "not found" in message:
|
||
return False
|
||
raise
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_capture_branches_worktree_snapshot(
|
||
open_pr_branches: list[str] | None = None,
|
||
active_lock_branch: str | None = None,
|
||
leased_paths: list[str] | None = None,
|
||
worktree_path: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: capture ``branches/`` and worktree audit snapshot (#404)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
root = PROJECT_ROOT
|
||
if worktree_path:
|
||
root = os.path.realpath(os.path.abspath(worktree_path))
|
||
git_root = _get_git_root(root)
|
||
if git_root:
|
||
root = git_root
|
||
snapshot = worktree_cleanup_audit.capture_branches_worktree_snapshot(
|
||
root,
|
||
open_pr_branches=open_pr_branches,
|
||
active_lock_branch=active_lock_branch,
|
||
leased_paths=leased_paths,
|
||
)
|
||
return {"success": True, "snapshot": snapshot}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_assess_worktree_cleanup_integrity(
|
||
before_snapshot: dict,
|
||
after_snapshot: dict,
|
||
removals: list[dict] | None = None,
|
||
explained_missing: dict[str, str] | None = None,
|
||
) -> dict:
|
||
"""Read-only: reconcile cleanup before/after snapshots (#404)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"integrity_passed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
return worktree_cleanup_audit.assess_worktree_cleanup_integrity(
|
||
before=before_snapshot,
|
||
after=after_snapshot,
|
||
removals=removals,
|
||
explained_missing=explained_missing,
|
||
)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_reconcile_merged_cleanups(
|
||
dry_run: bool = True,
|
||
execute_confirmed: bool = False,
|
||
limit: int = 50,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Reconcile merged PRs by reporting and optionally cleaning remote branches and local worktrees.
|
||
|
||
Args:
|
||
dry_run: Defaults to True. When True, only builds the reconciliation report.
|
||
execute_confirmed: Must be True when dry_run=False.
|
||
limit: Max number of closed PRs to inspect.
|
||
remote: Known Gitea instance ('dadeschools' or 'prgs').
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with reconciliation report and any executed cleanup actions.
|
||
"""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
|
||
if not dry_run and not execute_confirmed:
|
||
raise ValueError(
|
||
"execute_confirmed must be True when dry_run=False (fail closed)"
|
||
)
|
||
|
||
if not dry_run:
|
||
exec_allowed, exec_reasons = (
|
||
audit_reconciliation_mode.check_cleanup_execution_allowed()
|
||
)
|
||
if not exec_allowed:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": exec_reasons,
|
||
"audit_phase": audit_reconciliation_mode.current_phase(),
|
||
}
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = repo_api_url(h, o, r)
|
||
closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
|
||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||
|
||
merged_closed: list[dict] = []
|
||
remote_branch_exists: dict[str, bool] = {}
|
||
head_on_master: dict[int, bool | None] = {}
|
||
master_ref = f"{remote}/master" if remote in REMOTES else "origin/master"
|
||
|
||
for pr in closed_prs:
|
||
if not (pr.get("merged") or pr.get("merged_at")):
|
||
continue
|
||
merged_closed.append(pr)
|
||
head_ref = (pr.get("head") or {}).get("ref") or ""
|
||
head_sha = (pr.get("head") or {}).get("sha")
|
||
if head_ref and head_ref not in remote_branch_exists:
|
||
remote_branch_exists[head_ref] = _remote_branch_exists(
|
||
h, o, r, auth, head_ref
|
||
)
|
||
head_on_master[int(pr["number"])] = merged_cleanup_reconcile.is_head_ancestor_of_ref(
|
||
PROJECT_ROOT, head_sha, master_ref
|
||
)
|
||
|
||
delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete")
|
||
report = merged_cleanup_reconcile.build_reconciliation_report(
|
||
project_root=PROJECT_ROOT,
|
||
closed_prs=merged_closed,
|
||
open_prs=open_prs,
|
||
remote_branch_exists=remote_branch_exists,
|
||
head_on_master=head_on_master,
|
||
delete_capability_allowed=delete_capability_allowed,
|
||
)
|
||
|
||
if dry_run:
|
||
report["dry_run"] = True
|
||
report["executed"] = False
|
||
return {"success": True, "performed": False, **report}
|
||
|
||
verify_preflight_purity(remote, task="reconcile_merged_cleanups")
|
||
actions: list[dict] = []
|
||
for entry in report.get("entries") or []:
|
||
head_branch = entry.get("head_branch") or ""
|
||
remote_assessment = entry.get("remote_branch") or {}
|
||
local_assessment = entry.get("local_worktree") or {}
|
||
|
||
if remote_assessment.get("safe_to_delete_remote"):
|
||
import urllib.parse
|
||
|
||
encoded = urllib.parse.quote(head_branch, safe="")
|
||
url = f"{base}/branches/{encoded}"
|
||
with _audited(
|
||
"delete_branch",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
target_branch=head_branch,
|
||
request_metadata={"branch": head_branch, "source": "reconcile_merged_cleanups"},
|
||
):
|
||
api_request("DELETE", url, auth)
|
||
actions.append(
|
||
{
|
||
"action": "delete_remote_branch",
|
||
"branch": head_branch,
|
||
"success": True,
|
||
}
|
||
)
|
||
|
||
if local_assessment.get("safe_to_remove_worktree"):
|
||
result = merged_cleanup_reconcile.remove_local_worktree(
|
||
PROJECT_ROOT, head_branch
|
||
)
|
||
actions.append({"action": "remove_local_worktree", **result})
|
||
|
||
report["dry_run"] = False
|
||
report["executed"] = True
|
||
report["actions"] = actions
|
||
return {"success": True, "performed": True, **report}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_authorize_reconciliation_cleanup_phase(
|
||
operator_approved: bool = False,
|
||
workflow_authorized: bool = False,
|
||
delete_capability_proven: bool = False,
|
||
safe_to_delete_remote: bool = False,
|
||
safe_to_remove_worktree: bool = False,
|
||
before_state: str = "",
|
||
after_state: str = "",
|
||
) -> dict:
|
||
"""Authorize cleanup phase after audit-only reconciliation (#419).
|
||
|
||
Requires operator or workflow approval, exact delete_branch capability proof,
|
||
branch/worktree safety proof, and before/after state snapshots. Audit phase
|
||
forbids branch deletion, worktree removal, pushes, and issue/PR mutations.
|
||
"""
|
||
delete_gate = _profile_operation_gate("gitea.branch.delete")
|
||
capability_ok = not bool(delete_gate)
|
||
if delete_capability_proven and delete_gate:
|
||
return {
|
||
"authorized": False,
|
||
"performed": False,
|
||
"delete_capability_verified": False,
|
||
"reasons": [
|
||
"delete_capability_proven=true but active profile lacks "
|
||
"gitea.branch.delete",
|
||
] + delete_gate,
|
||
"audit_phase": audit_reconciliation_mode.current_phase(),
|
||
}
|
||
result = audit_reconciliation_mode.authorize_cleanup_phase(
|
||
operator_approved=operator_approved,
|
||
workflow_authorized=workflow_authorized,
|
||
delete_capability_proven=delete_capability_proven and capability_ok,
|
||
safety_proof={
|
||
"safe_to_delete_remote": safe_to_delete_remote,
|
||
"safe_to_remove_worktree": safe_to_remove_worktree,
|
||
},
|
||
before_after_snapshot={
|
||
"before": (before_state or "").strip(),
|
||
"after": (after_state or "").strip(),
|
||
},
|
||
)
|
||
result["performed"] = bool(result.get("authorized"))
|
||
result["delete_capability_verified"] = capability_ok
|
||
return result
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_assess_already_landed_reconciliation(
|
||
pr_number: int,
|
||
target_branch: str = "master",
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: assess already-landed reconciliation for one open PR (#301).
|
||
|
||
Fetches the PR live, refreshes the target branch, checks ancestor proof,
|
||
and returns a capability plan for the active profile. Does not review,
|
||
approve, request changes, merge, or mutate Gitea state.
|
||
|
||
Args:
|
||
pr_number: Open PR to assess.
|
||
target_branch: Branch used for ancestry proof (default master).
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with eligibility proof, capability plan, and safe next action.
|
||
"""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
pr = api_request("GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth)
|
||
|
||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||
pr=pr,
|
||
project_root=PROJECT_ROOT,
|
||
remote=remote,
|
||
target_branch=target_branch,
|
||
)
|
||
|
||
profile = get_profile()
|
||
capabilities = reconciliation_workflow.profile_reconciliation_capabilities(
|
||
profile.get("allowed_operations"),
|
||
profile.get("forbidden_operations"),
|
||
)
|
||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||
assessment=assessment,
|
||
capabilities=capabilities,
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"performed": False,
|
||
"pr_number": pr_number,
|
||
"assessment": assessment,
|
||
"capabilities": capabilities,
|
||
"plan": plan,
|
||
"workflow_source": "skills/llm-project-workflow/workflows/reconcile-landed-pr.md",
|
||
"task_mode": "reconcile-landed-pr",
|
||
"review_merge_allowed": False,
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_scan_already_landed_open_prs(
|
||
target_branch: str = "master",
|
||
limit: int = 50,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: list open PRs already landed on the target branch (#301).
|
||
|
||
Traverses open PR inventory with pagination proof, assesses each candidate,
|
||
and returns PRs classified as ``ALREADY_LANDED_RECONCILE_REQUIRED``. Does
|
||
not review, approve, merge, or mutate Gitea state.
|
||
|
||
Args:
|
||
target_branch: Branch used for ancestry proof (default master).
|
||
limit: Max open PRs to inspect across pages.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with candidates, pagination metadata, and target branch SHA proof.
|
||
"""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
||
|
||
open_prs: list[dict] = []
|
||
current_page = 1
|
||
pages_fetched = 0
|
||
last_meta: dict = {}
|
||
per_page = min(50, max(1, int(limit)))
|
||
while pages_fetched < 100 and len(open_prs) < limit:
|
||
raw_page, last_meta = api_fetch_page(
|
||
url, auth, page=current_page, limit=per_page
|
||
)
|
||
pages_fetched += 1
|
||
remaining = limit - len(open_prs)
|
||
open_prs.extend(raw_page[:remaining])
|
||
if last_meta["is_final_page"] or len(open_prs) >= limit:
|
||
break
|
||
current_page += 1
|
||
|
||
pagination = {
|
||
"page": 1,
|
||
"per_page": per_page,
|
||
"returned_count": len(open_prs),
|
||
"has_more": False,
|
||
"next_page": None,
|
||
"is_final_page": bool(last_meta.get("is_final_page")),
|
||
"pages_fetched": pages_fetched,
|
||
"inventory_complete": bool(last_meta.get("is_final_page")),
|
||
"total_count": len(open_prs) if last_meta.get("is_final_page") else None,
|
||
}
|
||
|
||
target_fetch = reconciliation_workflow.fetch_target_branch(
|
||
PROJECT_ROOT, remote, target_branch
|
||
)
|
||
|
||
candidates: list[dict] = []
|
||
for pr in open_prs:
|
||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||
pr=pr,
|
||
project_root=PROJECT_ROOT,
|
||
remote=remote,
|
||
target_branch=target_branch,
|
||
target_fetch=target_fetch,
|
||
)
|
||
if assessment.get("eligibility_class") == (
|
||
reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED
|
||
):
|
||
candidates.append(assessment)
|
||
|
||
return {
|
||
"success": True,
|
||
"performed": False,
|
||
"target_branch": target_branch,
|
||
"target_branch_sha": target_fetch.get("target_branch_sha"),
|
||
"git_ref_mutations": (
|
||
[target_fetch["git_fetch_command"]]
|
||
if target_fetch.get("git_fetch_command")
|
||
else []
|
||
),
|
||
"candidates": candidates,
|
||
"candidate_count": len(candidates),
|
||
"pagination": pagination,
|
||
"task_mode": "reconcile-landed-pr",
|
||
"review_merge_allowed": False,
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_audit_worktree_cleanup(
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
ttl_hours: float = worktree_cleanup_audit.DEFAULT_TTL_HOURS,
|
||
) -> dict:
|
||
"""Read-only: classify every session-owned worktree under ``branches/`` (#401).
|
||
|
||
Audits the local ``branches/`` directory, classifying each worktree as
|
||
active open PR, active issue work, dirty local, clean stale removable,
|
||
detached review leftover, or unsafe/unknown. Open PR branch heads are
|
||
fetched live so a worktree tied to an open PR is never marked removable;
|
||
the active issue-lock branch is read from the local lock file and treated
|
||
as active work. Deletes nothing and mutates no Gitea state.
|
||
|
||
Fails closed if the live open-PR list cannot be fetched: without it,
|
||
removability cannot be proven, so no candidates are returned.
|
||
|
||
Args:
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
ttl_hours: Age (hours) after which a clean issue/conflict-fix
|
||
worktree becomes stale-removable (default from
|
||
GITEA_WORKTREE_TTL_HOURS).
|
||
|
||
Returns:
|
||
dict with per-worktree classifications, counts, removable
|
||
candidates, and the ``git worktree list`` verification proof.
|
||
"""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
|
||
try:
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
open_prs = api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth)
|
||
except Exception as exc:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"open_pr_state_verified": False,
|
||
"reasons": [
|
||
"could not fetch live open PRs; removability unverified "
|
||
f"(fail closed): {_redact(str(exc))}"
|
||
],
|
||
}
|
||
|
||
open_pr_branches = {
|
||
str((pr.get("head") or {}).get("ref"))
|
||
for pr in open_prs
|
||
if (pr.get("head") or {}).get("ref")
|
||
}
|
||
|
||
active_issue_branches: set[str] = set()
|
||
lock = merged_cleanup_reconcile.read_issue_lock(ISSUE_LOCK_FILE)
|
||
if lock and lock.get("branch_name"):
|
||
active_issue_branches.add(str(lock["branch_name"]).strip())
|
||
|
||
report = worktree_cleanup_audit.audit_branches_directory(
|
||
PROJECT_ROOT,
|
||
open_pr_branches=open_pr_branches,
|
||
active_issue_branches=active_issue_branches,
|
||
now=datetime.now(timezone.utc),
|
||
ttl_hours=ttl_hours,
|
||
)
|
||
return {
|
||
"success": True,
|
||
"performed": False,
|
||
"open_pr_state_verified": True,
|
||
"task_mode": "work-issue",
|
||
**report,
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_reconcile_already_landed_pr(
|
||
pr_number: int,
|
||
close_pr: bool = False,
|
||
close_linked_issue: bool = False,
|
||
post_comment: bool = False,
|
||
comment_body: str = "",
|
||
target_branch: str = "master",
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Reconcile an open PR whose head is already on the target branch (#310).
|
||
|
||
Read-only assessment always runs. PR close requires ``gitea.pr.close`` and
|
||
proof that the pinned PR head SHA is an ancestor of a freshly fetched
|
||
target branch. Arbitrary PR closure is denied.
|
||
|
||
Args:
|
||
pr_number: Open PR to reconcile.
|
||
close_pr: When True, close the PR after already-landed proof passes.
|
||
close_linked_issue: When True, close a linked issue after live fetch
|
||
when the issue is open and ``gitea.issue.close`` is allowed.
|
||
post_comment: When True, post ``comment_body`` on the PR thread.
|
||
comment_body: PR comment text when ``post_comment`` is True.
|
||
target_branch: Target branch used for ancestry proof (default master).
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with eligibility proof, optional mutations, and recovery guidance.
|
||
"""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
|
||
if close_pr:
|
||
close_block = _profile_operation_gate("gitea.pr.close")
|
||
if close_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"pr_number": pr_number,
|
||
"required_permission": "gitea.pr.close",
|
||
"reasons": close_block,
|
||
"permission_report": _permission_block_report("gitea.pr.close"),
|
||
"safe_next_action": (
|
||
"Launch the prgs-reconciler MCP namespace or configure a "
|
||
"profile with gitea.pr.close for already-landed cleanup."
|
||
),
|
||
}
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = repo_api_url(h, o, r)
|
||
pr_url = f"{base}/pulls/{pr_number}"
|
||
pr = api_request("GET", pr_url, auth)
|
||
|
||
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
|
||
pr=pr,
|
||
project_root=PROJECT_ROOT,
|
||
remote=remote,
|
||
target_branch=target_branch,
|
||
)
|
||
|
||
result: dict = {
|
||
"success": True,
|
||
"performed": False,
|
||
"pr_number": pr_number,
|
||
"pr_state": assessment.get("pr_state"),
|
||
"candidate_head_sha": assessment.get("candidate_head_sha"),
|
||
"target_branch": assessment.get("target_branch"),
|
||
"target_branch_sha": assessment.get("target_branch_sha"),
|
||
"ancestor_proof": assessment.get("ancestor_proof"),
|
||
"eligibility_class": assessment.get("eligibility_class"),
|
||
"linked_issue": assessment.get("linked_issue"),
|
||
"close_allowed": assessment.get("close_allowed"),
|
||
"git_ref_mutations": assessment.get("git_ref_mutations") or [],
|
||
"reasons": list(assessment.get("reasons") or []),
|
||
"pr_closed": False,
|
||
"issue_closed": False,
|
||
"pr_comment_posted": False,
|
||
}
|
||
|
||
if not assessment.get("close_allowed"):
|
||
result["success"] = False
|
||
result["safe_next_action"] = (
|
||
"Do not close this PR via the reconciler path until "
|
||
"already-landed proof passes."
|
||
)
|
||
return result
|
||
|
||
verify_preflight_purity(remote, task="reconcile_already_landed_pr")
|
||
|
||
if post_comment and comment_body.strip():
|
||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||
if comment_block:
|
||
result["success"] = False
|
||
result["reasons"].extend(comment_block)
|
||
result["permission_report"] = _permission_block_report(
|
||
"gitea.pr.comment"
|
||
)
|
||
return result
|
||
comment_url = f"{base}/issues/{pr_number}/comments"
|
||
with _audited(
|
||
"comment_pr",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
pr_number=pr_number,
|
||
request_metadata={"source": "reconcile_already_landed_pr"},
|
||
):
|
||
api_request("POST", comment_url, auth, {"body": comment_body.strip()})
|
||
result["pr_comment_posted"] = True
|
||
result["performed"] = True
|
||
|
||
if close_pr:
|
||
patch_url = f"{base}/pulls/{pr_number}"
|
||
request_metadata = {
|
||
"source": "reconcile_already_landed_pr",
|
||
"required_permission": "gitea.pr.close",
|
||
"candidate_head_sha": assessment.get("candidate_head_sha"),
|
||
"target_branch_sha": assessment.get("target_branch_sha"),
|
||
"ancestor_proof": assessment.get("ancestor_proof"),
|
||
}
|
||
with _audited(
|
||
"close_pr",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
pr_number=pr_number,
|
||
request_metadata=request_metadata,
|
||
):
|
||
closed = api_request("PATCH", patch_url, auth, {"state": "closed"})
|
||
result["pr_closed"] = True
|
||
result["performed"] = True
|
||
result["pr_state"] = closed.get("state", "closed")
|
||
|
||
linked_issue = assessment.get("linked_issue")
|
||
if close_linked_issue and linked_issue:
|
||
issue_block = _profile_operation_gate("gitea.issue.close")
|
||
if issue_block:
|
||
result["reasons"].extend(issue_block)
|
||
result["issue_close_blocked"] = True
|
||
return result
|
||
issue_url = f"{base}/issues/{linked_issue}"
|
||
issue = api_request("GET", issue_url, auth)
|
||
result["linked_issue_live_status"] = issue.get("state")
|
||
if (issue.get("state") or "").lower() == "open":
|
||
with _audited(
|
||
"close_issue",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
issue_number=linked_issue,
|
||
request_metadata={"source": "reconcile_already_landed_pr"},
|
||
):
|
||
api_request(
|
||
"PATCH",
|
||
issue_url,
|
||
auth,
|
||
{"state": "closed"},
|
||
)
|
||
result["issue_closed"] = True
|
||
result["performed"] = True
|
||
|
||
return result
|
||
|
||
@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, task="close_issue")
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
|
||
with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
|
||
issue_number=issue_number, request_metadata={"state": "closed"}):
|
||
api_request("PATCH", url, auth, {"state": "closed"})
|
||
|
||
cleanup_result = release_in_progress_label([issue_number], remote, host, org, repo)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Issue #{issue_number} closed.",
|
||
"cleanup_status": cleanup_result
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_list_issues(
|
||
state: str = "open",
|
||
label: str | None = None,
|
||
limit: int = 50,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> list[dict]:
|
||
"""List issues on a Gitea repository with optional filters.
|
||
|
||
Args:
|
||
state: Filter by state — 'open', 'closed', or 'all'.
|
||
label: Filter by label name (e.g. 'important').
|
||
limit: Max number of issues to return across all pages (default: 50).
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
List of dicts with 'number', 'title', 'state', 'labels', 'assignee'.
|
||
"""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
params = f"state={state}&type=issues"
|
||
if label:
|
||
params += f"&labels={label}"
|
||
url = f"{repo_api_url(h, o, r)}/issues?{params}"
|
||
issues = api_get_all(url, auth, limit=limit)
|
||
return [
|
||
{
|
||
"number": i["number"],
|
||
"title": i["title"],
|
||
"state": i["state"],
|
||
"labels": [lb["name"] for lb in i.get("labels", [])],
|
||
"assignee": (i.get("assignee") or {}).get("login", ""),
|
||
}
|
||
for i in issues
|
||
]
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_view_issue(
|
||
issue_number: int,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Get full details of a single issue.
|
||
|
||
Args:
|
||
issue_number: The issue number to view.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with 'number', 'title', 'body', 'state', 'labels', and 'assignee'
|
||
('url' only with the reveal opt-in).
|
||
"""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
|
||
i = api_request("GET", url, auth)
|
||
result = {
|
||
"number": i["number"],
|
||
"title": i["title"],
|
||
"body": i.get("body", ""),
|
||
"state": i["state"],
|
||
"labels": [lb["name"] for lb in i.get("labels", [])],
|
||
"assignee": (i.get("assignee") or {}).get("login", ""),
|
||
}
|
||
return _with_optional_url(result, i.get("html_url"))
|
||
|
||
|
||
def _permission_block_report(required_operation: str,
|
||
identity: str | None = None) -> dict:
|
||
"""Structured, LLM-safe explanation of a permission denial (#142).
|
||
|
||
Built only after a gate has already refused; it adds guidance to the
|
||
refusal and never widens any permission, performs network I/O, or
|
||
raises (fail-soft: degrades to a minimal fail-closed report). Names
|
||
configured profiles only — never auth references, tokens, endpoint
|
||
URLs, or keychain IDs.
|
||
"""
|
||
report = {
|
||
"requested_operation": required_operation,
|
||
"missing_permission": required_operation,
|
||
"required_permission": required_operation,
|
||
"active_profile": None,
|
||
"active_identity": identity,
|
||
"active_allowed_operations": [],
|
||
"matching_configured_profiles": [],
|
||
"runtime_switching_supported": False,
|
||
"different_mcp_namespace_required": True,
|
||
"exact_safe_next_action": (
|
||
"Ask the operator to fix GITEA_MCP_CONFIG/GITEA_MCP_PROFILE; "
|
||
"the active profile could not be resolved (fail closed)."),
|
||
}
|
||
try:
|
||
profile = get_profile()
|
||
except Exception:
|
||
return report
|
||
report["active_profile"] = profile.get("profile_name")
|
||
if identity is None:
|
||
report["active_identity"] = profile.get("identity")
|
||
report["active_allowed_operations"] = (
|
||
profile.get("allowed_operations") or [])
|
||
|
||
matching = []
|
||
try:
|
||
config = gitea_config.load_config() or {}
|
||
for name, p in (config.get("profiles") or {}).items():
|
||
p_allowed = []
|
||
for op in p.get("allowed_operations") or []:
|
||
try:
|
||
p_allowed.append(gitea_config.normalize_operation(op))
|
||
except Exception:
|
||
pass
|
||
p_forbidden = []
|
||
for op in p.get("forbidden_operations") or []:
|
||
try:
|
||
p_forbidden.append(gitea_config.normalize_operation(op))
|
||
except Exception:
|
||
pass
|
||
ok, _ = gitea_config.check_operation(
|
||
required_operation, p_allowed, p_forbidden)
|
||
if ok:
|
||
matching.append(name)
|
||
except Exception:
|
||
matching = []
|
||
report["matching_configured_profiles"] = matching
|
||
|
||
try:
|
||
switching = gitea_config.is_runtime_switching_enabled()
|
||
except Exception:
|
||
switching = False
|
||
report["runtime_switching_supported"] = switching
|
||
|
||
role = "reviewer" if required_operation in (
|
||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes",
|
||
"gitea.pr.review") else "author"
|
||
if switching and matching:
|
||
report["different_mcp_namespace_required"] = False
|
||
report["exact_safe_next_action"] = (
|
||
f"Call gitea_activate_profile with a profile that allows "
|
||
f"{required_operation} (matching configured profiles: "
|
||
f"{matching}).")
|
||
else:
|
||
report["different_mcp_namespace_required"] = True
|
||
report["exact_safe_next_action"] = (
|
||
f"Switch to the {role} MCP session (e.g. gitea-{role}), or ask "
|
||
f"the operator to set GITEA_MCP_PROFILE to a profile that "
|
||
f"allows {required_operation}.")
|
||
return report
|
||
|
||
|
||
def _role_for_operation(op: str) -> str | None:
|
||
# Normalize op first
|
||
try:
|
||
op_n = gitea_config.normalize_operation(op)
|
||
except Exception:
|
||
op_n = op
|
||
if op_n in ("gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review", "gitea.pr.request_changes"):
|
||
return "reviewer"
|
||
if op_n in (
|
||
"gitea.issue.create",
|
||
"gitea.issue.comment",
|
||
"gitea.issue.close",
|
||
"gitea.branch.create",
|
||
"gitea.branch.push",
|
||
"gitea.pr.create",
|
||
"gitea.pr.comment",
|
||
"gitea.pr.close",
|
||
"gitea.branch.delete",
|
||
"gitea.repo.commit"
|
||
):
|
||
return "author"
|
||
return None
|
||
|
||
|
||
def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
|
||
"""Try to find a profile in config that allows op and has valid credentials.
|
||
|
||
If found, switch to it, clear identity cache, and return True.
|
||
Otherwise return False.
|
||
"""
|
||
role = _role_for_operation(op)
|
||
if not role:
|
||
return False
|
||
if not gitea_config.is_runtime_switching_enabled():
|
||
return False
|
||
config = gitea_config.load_config()
|
||
if not config or "profiles" not in config:
|
||
return False
|
||
for p_name, p_data in config["profiles"].items():
|
||
# Role classification matching
|
||
p_role = p_data.get("role") or _role_kind(p_data.get("allowed_operations", []), p_data.get("forbidden_operations", []))
|
||
if p_role != role:
|
||
continue
|
||
p_allowed = p_data.get("allowed_operations") or []
|
||
p_forbidden = p_data.get("forbidden_operations") or []
|
||
p_allowed_n = []
|
||
for op_val in p_allowed:
|
||
try:
|
||
p_allowed_n.append(gitea_config.normalize_operation(op_val))
|
||
except Exception:
|
||
pass
|
||
p_forbidden_n = []
|
||
for op_val in p_forbidden:
|
||
try:
|
||
p_forbidden_n.append(gitea_config.normalize_operation(op_val))
|
||
except Exception:
|
||
pass
|
||
ok, _ = gitea_config.check_operation(op, p_allowed_n, p_forbidden_n)
|
||
if ok:
|
||
try:
|
||
tok = gitea_config.resolve_token(p_data)
|
||
if tok:
|
||
gitea_config._active_profile_override = p_name
|
||
_IDENTITY_CACHE.clear()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _profile_operation_gate(op: str) -> list[str]:
|
||
"""Profile permission check for a single gated operation (#126, #216).
|
||
|
||
Issue discussion comments are gated separately from the gitea.pr.*
|
||
review/merge family: listing requires ``gitea.read``, creating requires
|
||
``gitea.issue.comment``. Closing a PR requires the distinct
|
||
``gitea.pr.close`` (#216). Returns a list of block reasons (empty =
|
||
allowed); an unreadable profile fails closed.
|
||
"""
|
||
try:
|
||
profile = get_profile()
|
||
except Exception as exc:
|
||
return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
|
||
op_ok, op_reason = gitea_config.check_operation(
|
||
op, profile["allowed_operations"], profile["forbidden_operations"])
|
||
if op_ok:
|
||
return []
|
||
|
||
if _try_auto_switch_for_operation(op):
|
||
try:
|
||
profile = get_profile()
|
||
op_ok, op_reason = gitea_config.check_operation(
|
||
op, profile["allowed_operations"], profile["forbidden_operations"])
|
||
if op_ok:
|
||
return []
|
||
except Exception as exc:
|
||
return [f"profile could not be resolved (fail closed): {_redact(str(exc))}"]
|
||
|
||
if op_reason == "no-allowed-operations":
|
||
return ["profile has no configured allowed operations (fail closed)"]
|
||
if op_reason == "forbidden":
|
||
return [f"profile forbids '{op}'"]
|
||
if op_reason == "invalid-forbidden-entry":
|
||
return ["profile has an unrecognized forbidden operation entry (fail closed)"]
|
||
return [f"profile is not allowed to {op}"]
|
||
|
||
|
||
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
|
||
"""Structured permission denial for gated tools (#69, #142).
|
||
|
||
Returns a block dict when the active profile forbids *required_operation*,
|
||
or ``None`` when the gate passes. Never performs network I/O.
|
||
"""
|
||
req_role = "reviewer" if any(required_operation.startswith(p) for p in (
|
||
"gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes", "gitea.pr.review"
|
||
)) else "author"
|
||
_ensure_matching_profile(required_operation, req_role, extra_fields.get("remote"))
|
||
|
||
reasons = _profile_operation_gate(required_operation)
|
||
if not reasons:
|
||
return None
|
||
blocked = {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": reasons,
|
||
"permission_report": _permission_block_report(required_operation),
|
||
}
|
||
blocked.update(extra_fields)
|
||
return blocked
|
||
|
||
|
||
def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None:
|
||
"""Reviewer/author namespace alignment gate (#209)."""
|
||
required_permission = task_capability_map.required_permission(mutation_task)
|
||
required_role = task_capability_map.required_role(mutation_task)
|
||
_ensure_matching_profile(required_permission, required_role, extra_fields.get("remote"))
|
||
|
||
try:
|
||
profile = get_profile()
|
||
except Exception as exc:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": [
|
||
f"profile could not be resolved (fail closed): {_redact(str(exc))}"],
|
||
**extra_fields,
|
||
}
|
||
ok, reasons = role_namespace_gate.check_author_mutation_namespace(
|
||
mutation_task, profile)
|
||
if ok:
|
||
return None
|
||
blocked = {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": reasons,
|
||
"namespace_block": True,
|
||
"mcp_namespace": role_namespace_gate.infer_mcp_namespace(
|
||
profile.get("profile_name")),
|
||
**extra_fields,
|
||
}
|
||
_audit(
|
||
mutation_task,
|
||
host=None,
|
||
remote=extra_fields.get("remote"),
|
||
result=gitea_audit.BLOCKED,
|
||
repo=extra_fields.get("repository"),
|
||
reason="; ".join(reasons),
|
||
mutation_task=mutation_task,
|
||
)
|
||
return blocked
|
||
|
||
|
||
def _fetch_pr_comments(
|
||
pr_number: int,
|
||
*,
|
||
remote: str,
|
||
host: str | None,
|
||
org: str | None,
|
||
repo: str | None,
|
||
) -> list[dict]:
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
return api_request("GET", api, auth) or []
|
||
|
||
|
||
def _reviewer_pr_lease_gate(
|
||
*,
|
||
pr_number: int,
|
||
remote: str,
|
||
host: str | None,
|
||
org: str | None,
|
||
repo: str | None,
|
||
mutation: str,
|
||
live_head_sha: str | None,
|
||
pinned_head_sha: str | None,
|
||
) -> list[str]:
|
||
"""Return block reasons when the session lacks an owned PR reviewer lease."""
|
||
session = reviewer_pr_lease.get_session_lease()
|
||
session_id = (session or {}).get("session_id")
|
||
identity = _authenticated_username(remote) or ""
|
||
try:
|
||
comments = _fetch_pr_comments(
|
||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||
except Exception as exc:
|
||
return [f"cannot fetch PR comments for lease gate: {_redact(str(exc))}"]
|
||
assessment = reviewer_pr_lease.assess_mutation_lease_gate(
|
||
pr_number=pr_number,
|
||
comments=comments,
|
||
reviewer_identity=identity,
|
||
session_id=session_id,
|
||
mutation=mutation,
|
||
live_head_sha=live_head_sha,
|
||
pinned_head_sha=pinned_head_sha,
|
||
)
|
||
return list(assessment.get("reasons") or []) if assessment.get("block") else []
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_acquire_reviewer_pr_lease(
|
||
pr_number: int,
|
||
worktree: str,
|
||
candidate_head: str | None = None,
|
||
target_branch: str = "master",
|
||
target_branch_sha: str | None = None,
|
||
issue_number: int | None = None,
|
||
session_id: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Acquire a per-PR reviewer lease before review/merge mutations (#407)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"acquired": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||
if comment_block:
|
||
return {
|
||
"success": False,
|
||
"acquired": False,
|
||
"reasons": comment_block,
|
||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||
}
|
||
|
||
_verify_role_mutation_workspace(remote, worktree=worktree, task="review_pr")
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
profile = get_profile()
|
||
identity = _authenticated_username(remote) or profile.get("username") or ""
|
||
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
|
||
repo_label = f"{o}/{r}"
|
||
|
||
comments = _fetch_pr_comments(
|
||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||
# Refuse merge-oriented lease acquisition/adoption on an already-merged or
|
||
# closed PR: the lease is moot and adopting it for merge work is unsafe (#515).
|
||
pr_live = api_request(
|
||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||
pr_merged_or_closed = bool(
|
||
pr_live.get("merged") or pr_live.get("merged_at")
|
||
) or (str(pr_live.get("state") or "").strip().lower() == "closed")
|
||
assessment = reviewer_pr_lease.assess_acquire_lease(
|
||
comments,
|
||
pr_number=pr_number,
|
||
reviewer_identity=identity,
|
||
profile=profile.get("profile_name") or "unknown",
|
||
session_id=sid,
|
||
repo=repo_label,
|
||
issue_number=issue_number,
|
||
worktree=worktree,
|
||
candidate_head=candidate_head,
|
||
target_branch=target_branch,
|
||
target_branch_sha=target_branch_sha,
|
||
pr_merged_or_closed=pr_merged_or_closed,
|
||
)
|
||
if not assessment.get("acquire_allowed"):
|
||
return {
|
||
"success": False,
|
||
"acquired": False,
|
||
"reasons": assessment.get("reasons") or [],
|
||
"existing_lease": assessment.get("existing_lease"),
|
||
"post_merge_moot": assessment.get("post_merge_moot", False),
|
||
}
|
||
|
||
body = assessment["lease_body"]
|
||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
with _audited(
|
||
"comment_pr",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
pr_number=pr_number,
|
||
request_metadata={"source": "acquire_reviewer_pr_lease"},
|
||
):
|
||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||
|
||
session_lease = reviewer_pr_lease.record_session_lease({
|
||
"pr_number": pr_number,
|
||
"issue_number": issue_number,
|
||
"session_id": sid,
|
||
"reviewer_identity": identity,
|
||
"profile": profile.get("profile_name"),
|
||
"worktree": worktree,
|
||
"phase": "claimed",
|
||
"candidate_head": candidate_head,
|
||
"target_branch": target_branch,
|
||
"target_branch_sha": target_branch_sha,
|
||
"repo": repo_label,
|
||
"comment_id": posted.get("id"),
|
||
}, lease_provenance=merger_lease_adoption.build_lease_provenance(
|
||
source=merger_lease_adoption.SOURCE_ACQUIRE,
|
||
comment_id=posted.get("id"),
|
||
))
|
||
return {
|
||
"success": True,
|
||
"acquired": True,
|
||
"pr_number": pr_number,
|
||
"session_id": sid,
|
||
"comment_id": posted.get("id"),
|
||
"session_lease": session_lease,
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_adopt_merger_pr_lease(
|
||
pr_number: int,
|
||
worktree: str,
|
||
expected_head_sha: str,
|
||
issue_number: int | None = None,
|
||
session_id: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Adopt an active reviewer PR lease for a merger session (#536).
|
||
|
||
Merger sessions must not manually seed ``reviewer_pr_lease._SESSION_LEASE``.
|
||
This tool posts durable adoption proof on the PR thread and records
|
||
sanctioned in-session lease provenance so ``gitea_merge_pr`` can proceed
|
||
after a separate reviewer session held the lease.
|
||
|
||
Args:
|
||
pr_number: Open PR to adopt.
|
||
worktree: Merger workspace under ``branches/``.
|
||
expected_head_sha: Approved head the merger will pin during merge.
|
||
issue_number: Optional linked issue for the adoption record.
|
||
session_id: Optional merger session id (generated when omitted).
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict with adoption proof, session lease, and block reasons when the
|
||
guarded preconditions are not met.
|
||
"""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"adopted": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||
if comment_block:
|
||
return {
|
||
"success": False,
|
||
"adopted": False,
|
||
"reasons": comment_block,
|
||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||
}
|
||
merge_block = _profile_operation_gate("gitea.pr.merge")
|
||
if merge_block:
|
||
return {
|
||
"success": False,
|
||
"adopted": False,
|
||
"reasons": merge_block,
|
||
"permission_report": _permission_block_report("gitea.pr.merge"),
|
||
}
|
||
|
||
_verify_role_mutation_workspace(
|
||
remote, worktree=worktree, task="adopt_merger_pr_lease"
|
||
)
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
profile = get_profile()
|
||
profile_name = profile.get("profile_name") or "unknown"
|
||
identity = _authenticated_username(remote) or profile.get("username") or ""
|
||
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
|
||
repo_label = f"{o}/{r}"
|
||
|
||
pr_live = api_request(
|
||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||
pr_state = (pr_live.get("state") or "").strip().lower()
|
||
pr_open = pr_state == "open" and not pr_live.get("merged")
|
||
live_head = (pr_live.get("head", {}) or {}).get("sha") or pr_live.get(
|
||
"head_sha"
|
||
)
|
||
|
||
feedback = gitea_get_pr_review_feedback(
|
||
pr_number=pr_number,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
approval_at_head = bool(feedback.get("approval_at_current_head"))
|
||
|
||
comments = _fetch_pr_comments(
|
||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||
assessment = merger_lease_adoption.assess_adopt_merger_lease(
|
||
comments,
|
||
pr_number=pr_number,
|
||
adopter_identity=identity,
|
||
adopter_profile=profile_name,
|
||
adopter_session_id=sid,
|
||
repo=repo_label,
|
||
issue_number=issue_number,
|
||
worktree=worktree,
|
||
expected_head_sha=expected_head_sha,
|
||
live_head_sha=live_head,
|
||
approval_at_head=approval_at_head,
|
||
pr_open=pr_open,
|
||
)
|
||
if not assessment.get("adopt_allowed"):
|
||
return {
|
||
"success": False,
|
||
"adopted": False,
|
||
"pr_number": pr_number,
|
||
"reasons": assessment.get("reasons") or [],
|
||
"active_lease": assessment.get("active_lease"),
|
||
"expected_head_sha": expected_head_sha,
|
||
"live_head_sha": live_head,
|
||
}
|
||
|
||
active = assessment.get("active_lease") or {}
|
||
body = assessment["adoption_body"]
|
||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
with _audited(
|
||
"comment_pr",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
pr_number=pr_number,
|
||
request_metadata={"source": "adopt_merger_pr_lease"},
|
||
):
|
||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||
|
||
provenance = merger_lease_adoption.build_lease_provenance(
|
||
source=merger_lease_adoption.SOURCE_ADOPT,
|
||
comment_id=posted.get("id"),
|
||
adopted_from_session_id=active.get("session_id"),
|
||
adopted_from_profile=active.get("profile"),
|
||
adopted_from_reviewer_identity=active.get("reviewer_identity"),
|
||
adoption_reason=merger_lease_adoption.DEFAULT_ADOPTION_REASON,
|
||
)
|
||
session_lease = reviewer_pr_lease.record_session_lease({
|
||
"pr_number": pr_number,
|
||
"issue_number": issue_number or active.get("issue_number"),
|
||
"session_id": sid,
|
||
"reviewer_identity": identity,
|
||
"profile": profile_name,
|
||
"worktree": worktree,
|
||
"phase": "adopted",
|
||
"candidate_head": live_head,
|
||
"target_branch": active.get("target_branch") or "master",
|
||
"target_branch_sha": active.get("target_branch_sha"),
|
||
"repo": repo_label,
|
||
"comment_id": posted.get("id"),
|
||
}, lease_provenance=provenance)
|
||
|
||
return {
|
||
"success": True,
|
||
"adopted": True,
|
||
"pr_number": pr_number,
|
||
"session_id": sid,
|
||
"adoption_comment_id": posted.get("id"),
|
||
"adopted_from_session_id": active.get("session_id"),
|
||
"adopted_from_profile": active.get("profile"),
|
||
"adopted_from_reviewer_identity": active.get("reviewer_identity"),
|
||
"adoption_reason": merger_lease_adoption.DEFAULT_ADOPTION_REASON,
|
||
"expected_head_sha": expected_head_sha,
|
||
"live_head_sha": live_head,
|
||
"session_lease": session_lease,
|
||
"lease_provenance": provenance,
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_heartbeat_reviewer_pr_lease(
|
||
pr_number: int,
|
||
phase: str,
|
||
worktree: str | None = None,
|
||
candidate_head: str | None = None,
|
||
target_branch_sha: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Post a reviewer lease heartbeat / phase update on the PR thread (#407)."""
|
||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||
if comment_block:
|
||
return {
|
||
"success": False,
|
||
"posted": False,
|
||
"reasons": comment_block,
|
||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||
}
|
||
session = reviewer_pr_lease.get_session_lease()
|
||
if not session or session.get("pr_number") != pr_number:
|
||
return {
|
||
"success": False,
|
||
"posted": False,
|
||
"reasons": [
|
||
f"no in-session lease for PR #{pr_number}; acquire first "
|
||
"(fail closed)"
|
||
],
|
||
}
|
||
|
||
verify_preflight_purity(remote, task="review_pr")
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
body = reviewer_pr_lease.format_lease_body(
|
||
repo=f"{o}/{r}",
|
||
pr_number=pr_number,
|
||
issue_number=session.get("issue_number"),
|
||
reviewer_identity=session.get("reviewer_identity") or "",
|
||
profile=session.get("profile") or "unknown",
|
||
session_id=session.get("session_id") or reviewer_pr_lease.new_session_id(),
|
||
worktree=worktree or session.get("worktree") or "",
|
||
phase=phase,
|
||
candidate_head=candidate_head or session.get("candidate_head"),
|
||
target_branch=session.get("target_branch") or "master",
|
||
target_branch_sha=target_branch_sha or session.get("target_branch_sha"),
|
||
)
|
||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
with _audited(
|
||
"comment_pr",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
pr_number=pr_number,
|
||
request_metadata={"source": "heartbeat_reviewer_pr_lease", "phase": phase},
|
||
):
|
||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||
|
||
prior_provenance = (session.get("lease_provenance") or {}).copy()
|
||
heartbeat_provenance = merger_lease_adoption.build_lease_provenance(
|
||
source=merger_lease_adoption.SOURCE_HEARTBEAT,
|
||
comment_id=posted.get("id"),
|
||
adopted_from_session_id=prior_provenance.get("adopted_from_session_id"),
|
||
adopted_from_profile=prior_provenance.get("adopted_from_profile"),
|
||
adopted_from_reviewer_identity=prior_provenance.get(
|
||
"adopted_from_reviewer_identity"
|
||
),
|
||
adoption_reason=prior_provenance.get("adoption_reason"),
|
||
)
|
||
updated = reviewer_pr_lease.record_session_lease({
|
||
**session,
|
||
"phase": phase,
|
||
"worktree": worktree or session.get("worktree"),
|
||
"candidate_head": candidate_head or session.get("candidate_head"),
|
||
"target_branch_sha": target_branch_sha or session.get("target_branch_sha"),
|
||
"last_comment_id": posted.get("id"),
|
||
"comment_id": posted.get("id"),
|
||
}, lease_provenance=heartbeat_provenance)
|
||
return {
|
||
"success": True,
|
||
"posted": True,
|
||
"pr_number": pr_number,
|
||
"phase": phase,
|
||
"comment_id": posted.get("id"),
|
||
"session_lease": updated,
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_assess_reviewer_pr_lease(
|
||
pr_number: int,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: assess active reviewer lease state for a PR (#407)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
comments = _fetch_pr_comments(
|
||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||
active = reviewer_pr_lease.find_active_reviewer_lease(
|
||
comments, pr_number=pr_number)
|
||
return {
|
||
"success": True,
|
||
"pr_number": pr_number,
|
||
"active_lease": active,
|
||
"session_lease": reviewer_pr_lease.get_session_lease(),
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_cleanup_post_merge_moot_lease(
|
||
pr_number: int,
|
||
apply: bool = False,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Safely resolve a moot reviewer lease left on an ALREADY-MERGED/closed PR (#515).
|
||
|
||
Read-first and fail-safe. This tool never merges and never adopts a lease.
|
||
It only acts when the live PR state is merged/closed, and it never steals or
|
||
force-cleans an active *foreign* lease while the PR is still open. When
|
||
``apply`` is true and a lease is still active on a merged/closed PR, it posts
|
||
a terminal ``phase: released`` lease marker (``blocker: post-merge-moot``) —
|
||
an append-only comment that neutralises the moot lease without deleting any
|
||
other session's comment.
|
||
|
||
Args:
|
||
pr_number: The PR whose lingering lease to assess/clean.
|
||
apply: When false (default) report only (read-only). When true, post the
|
||
terminal released marker if — and only if — cleanup is allowed.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict reporting PR merged/closed state, merge_commit_sha, linked-issue
|
||
closure state, whether the lease is moot, whether cleanup was performed
|
||
or skipped (and why), and ``no_merge_or_adoption`` True — this path never
|
||
merges or adopts.
|
||
"""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"cleanup_performed": False,
|
||
"no_merge_or_adoption": True,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
pr_live = api_request(
|
||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||
comments = _fetch_pr_comments(
|
||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||
assessment = reviewer_pr_lease.assess_post_merge_moot_lease(
|
||
comments,
|
||
pr_number=pr_number,
|
||
pr_merged=bool(pr_live.get("merged") or pr_live.get("merged_at")),
|
||
pr_state=pr_live.get("state"),
|
||
merge_commit_sha=pr_live.get("merge_commit_sha"),
|
||
)
|
||
|
||
# Best-effort linked-issue closure state for the report.
|
||
active = assessment.get("active_lease") or {}
|
||
issue_no = active.get("issue_number")
|
||
linked_issue_state = None
|
||
if issue_no:
|
||
try:
|
||
issue = api_request(
|
||
"GET", f"{repo_api_url(h, o, r)}/issues/{issue_no}", auth) or {}
|
||
linked_issue_state = issue.get("state")
|
||
except Exception:
|
||
linked_issue_state = None
|
||
|
||
report = {
|
||
"success": True,
|
||
"pr_number": pr_number,
|
||
"pr_state": assessment.get("pr_state"),
|
||
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
||
"merge_commit_sha": assessment.get("merge_commit_sha"),
|
||
"linked_issue_number": issue_no,
|
||
"linked_issue_state": linked_issue_state,
|
||
"lease_moot": assessment.get("is_moot"),
|
||
"active_lease": assessment.get("active_lease"),
|
||
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
||
"cleanup_performed": False,
|
||
"no_merge_or_adoption": True,
|
||
"mode": "apply" if apply else "read_only",
|
||
"reasons": assessment.get("reasons") or [],
|
||
}
|
||
|
||
if not apply:
|
||
return report
|
||
if not assessment.get("cleanup_allowed"):
|
||
report["cleanup_skipped_reason"] = (
|
||
assessment.get("reasons") or ["cleanup not allowed"]
|
||
)
|
||
return report
|
||
|
||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||
if comment_block:
|
||
report["success"] = False
|
||
report["reasons"] = comment_block
|
||
report["permission_report"] = _permission_block_report("gitea.pr.comment")
|
||
return report
|
||
|
||
verify_preflight_purity(remote)
|
||
body = assessment["release_body"]
|
||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
with _audited(
|
||
"comment_pr",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
pr_number=pr_number,
|
||
request_metadata={"source": "cleanup_post_merge_moot_lease"},
|
||
):
|
||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||
|
||
report["cleanup_performed"] = True
|
||
report["released_comment_id"] = posted.get("id")
|
||
return report
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_release_reviewer_pr_lease(
|
||
pr_number: int,
|
||
worktree: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Safely release an active reviewer lease owned by the current session on an open PR.
|
||
|
||
This provides a canonical way to clean up reviewer leases when a review fails
|
||
before submission.
|
||
|
||
Args:
|
||
pr_number: The PR number whose reviewer lease to release.
|
||
worktree: Optional worktree path (resolves automatically if not supplied).
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict reporting release status.
|
||
"""
|
||
_verify_role_mutation_workspace(
|
||
remote, worktree=worktree, task="review_pr"
|
||
)
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
|
||
comments = _fetch_pr_comments(
|
||
pr_number, remote=remote, host=host, org=org, repo=repo
|
||
)
|
||
active = reviewer_pr_lease.find_active_reviewer_lease(
|
||
comments, pr_number=pr_number
|
||
)
|
||
|
||
if not active:
|
||
return {
|
||
"success": True,
|
||
"released": False,
|
||
"reasons": ["no active reviewer lease found on PR"],
|
||
}
|
||
|
||
identity = _authenticated_username(remote) or ""
|
||
session = reviewer_pr_lease.get_session_lease() or {}
|
||
session_id = session.get("session_id")
|
||
|
||
owner_identity = active.get("reviewer_identity")
|
||
owner_session_id = active.get("session_id")
|
||
|
||
# Authorize release: identity matches, session_id matches, or lease is expired/reclaimable
|
||
authorized = False
|
||
reasons = []
|
||
if owner_identity and identity and owner_identity == identity:
|
||
authorized = True
|
||
elif owner_session_id and session_id and owner_session_id == session_id:
|
||
authorized = True
|
||
else:
|
||
freshness = reviewer_pr_lease.classify_lease_freshness(active)
|
||
if freshness in {"expired", "reclaimable"}:
|
||
authorized = True
|
||
|
||
if not authorized:
|
||
return {
|
||
"success": False,
|
||
"released": False,
|
||
"reasons": [
|
||
f"unauthorized to release active lease: owned by {owner_identity} "
|
||
f"(session {owner_session_id})"
|
||
],
|
||
}
|
||
|
||
# Format and post release comment
|
||
body = reviewer_pr_lease.format_lease_body(
|
||
repo=active.get("repo") or f"{o}/{r}",
|
||
pr_number=pr_number,
|
||
issue_number=active.get("issue_number"),
|
||
reviewer_identity=owner_identity or identity,
|
||
profile=active.get("profile") or "reviewer",
|
||
session_id=owner_session_id or session_id or "unknown",
|
||
worktree=active.get("worktree") or "",
|
||
phase="released",
|
||
candidate_head=active.get("candidate_head"),
|
||
target_branch=active.get("target_branch") or "master",
|
||
target_branch_sha=active.get("target_branch_sha"),
|
||
blocker="manual-release",
|
||
)
|
||
|
||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||
with _audited(
|
||
"comment_pr",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
pr_number=pr_number,
|
||
request_metadata={"source": "release_reviewer_pr_lease"},
|
||
):
|
||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||
|
||
reviewer_pr_lease.clear_session_lease()
|
||
|
||
return {
|
||
"success": True,
|
||
"released": True,
|
||
"comment_id": posted.get("id"),
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
@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)
|
||
# Fail safe to no comments when the API returns a non-list payload (e.g. an
|
||
# error object such as an HTTP 401 body) so listing never crashes on a
|
||
# malformed response (#485).
|
||
if not isinstance(comments, list):
|
||
comments = []
|
||
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,
|
||
worktree_path: 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.
|
||
worktree_path: Optional path to verify branches-only guard.
|
||
|
||
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, worktree_path=worktree_path, task="comment_issue")
|
||
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.
|
||
|
||
'reconciler' can close already-landed PRs without review/author powers;
|
||
'author' can create PRs / push branches; 'reviewer' can approve/merge;
|
||
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
|
||
review/author powers; 'mixed' can do both (a config smell — the
|
||
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
|
||
neither.
|
||
"""
|
||
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
|
||
return "reconciler"
|
||
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")
|
||
reconciler = (
|
||
can("gitea.pr.close")
|
||
and not review
|
||
and not author
|
||
)
|
||
if review and author:
|
||
return "mixed"
|
||
if review:
|
||
return "reviewer"
|
||
if reconciler:
|
||
return "reconciler"
|
||
if author:
|
||
return "author"
|
||
return "limited"
|
||
|
||
|
||
_HARD_STOPS = [
|
||
"No self-review and no self-merge: the authenticated user must differ "
|
||
"from the PR author for review/approve/merge.",
|
||
"No tags or releases without an explicit operator instruction.",
|
||
"Never print token values, keychain IDs, passwords, or raw service "
|
||
"URLs in normal output.",
|
||
"No production access or production mutations.",
|
||
"No force-merge and no bypassing of merge gates; Gitea's own "
|
||
"mergeable signal is honoured, never overridden.",
|
||
"Do not rewrite the live profiles config; config changes are "
|
||
"operator-owned.",
|
||
"Do not hardcode Gitea identities or assume profile roles from prompts. "
|
||
"Always call gitea_whoami to verify the active identity and profile, and "
|
||
"call gitea_resolve_task_capability to check required task permissions.",
|
||
"Use the correct namespace/profile: PR review and merge require a reviewer "
|
||
"namespace (e.g. gitea-reviewer), and branch/PR creation requires an author "
|
||
"namespace (e.g. gitea-author).",
|
||
"Issue comments require explicit gitea.issue.comment permission. PR "
|
||
"comments (gitea.pr.comment) do not imply or satisfy issue comment requirements.",
|
||
]
|
||
|
||
_GUIDE_RULES = {
|
||
"hard_stops": _HARD_STOPS,
|
||
"fail_closed": (
|
||
"Every gate fails closed: unknown/disabled profiles, unknown "
|
||
"operations, empty allowed-operation lists, unresolved identity, "
|
||
"and unparseable config all deny the action instead of guessing. "
|
||
"Disabled profiles/services are never silently substituted."),
|
||
"head_sha_pinning": (
|
||
"Before reviewing or merging, record the PR head SHA and pass it as "
|
||
"expected_head_sha; if the head moves between review and merge, the "
|
||
"tool refuses and the review must be redone."),
|
||
"merge_confirmation": (
|
||
"gitea_merge_pr requires confirmation to equal 'MERGE PR <n>' "
|
||
"exactly (e.g. 'MERGE PR 42'); without it no API call is made. "
|
||
"Merge only when the operator has explicitly authorized it."),
|
||
"redaction": [
|
||
"Normal output contains no endpoint URLs, keychain IDs, or token "
|
||
"values; auth is reported as type/status only.",
|
||
"GITEA_MCP_REVEAL_ENDPOINTS=1 is the local admin/debug opt-in for "
|
||
"web links and endpoint names; it never reveals token values.",
|
||
"Errors are redacted before being surfaced.",
|
||
],
|
||
"separation": (
|
||
"Author, reviewer, and merger are separate identities. An author "
|
||
"profile creates branches/commits/PRs and must never "
|
||
"review/approve/merge its own work; a reviewer/merger profile "
|
||
"reviews and merges and must never create PRs or push branches "
|
||
"(reviewer-deadlock invariant). Issue discussion comments "
|
||
"(gitea.issue.comment) are gated separately from PR reviews "
|
||
"(gitea.pr.*)."),
|
||
"profile_switching": [
|
||
"The active profile is selected via GITEA_MCP_PROFILE (with GITEA_MCP_CONFIG pointing at the config path).",
|
||
"By default, static-profile mode is active: changing local environment variables does not dynamically update the already-connected MCP server process.",
|
||
"If runtime profile switching is explicitly enabled in the config (allow_runtime_switching: true), use gitea_activate_profile to switch profiles in-place.",
|
||
"If switching is disabled, you must change the launcher configuration/environment (dual-namespace) and reconnect/restart the MCP session.",
|
||
"After any profile switch or startup, you must verify the new runtime identity with a fresh gitea_whoami call before taking action."
|
||
],
|
||
"identity_verification": [
|
||
"Call gitea_whoami with an explicit remote first; confirm the "
|
||
"authenticated username and profile match the role you were asked "
|
||
"to perform.",
|
||
"If the username, profile, or allowed operations do not match the "
|
||
"task, STOP and report instead of proceeding.",
|
||
"Runtime identity (whoami) is the source of truth; config-declared "
|
||
"roles are metadata only.",
|
||
"Do not hardcode Gitea identities; always dynamically verify your "
|
||
"identity and resolve task capability via gitea_resolve_task_capability.",
|
||
],
|
||
"live_state_reconciliation": [
|
||
"Never rely on prior handoffs, chat history, or cached reports for PR queue state.",
|
||
"Before review or merge decisions: always call gitea_list_prs + gitea_view_pr (for the specific PR), re-fetch remotes, pull master --ff-only, and compare live fields (state, mergeable, head SHA, updated_at, merged_at, merge_commit_sha) against any prior claims.",
|
||
"Conflicting or stale PR state (e.g. prior said merged but live open, or head/updated mismatch) is a hard blocker: report it explicitly and stop until live state is unambiguous.",
|
||
"After merge, re-list PRs and re-view the PR (plus verify master contains the merge) before claiming completion.",
|
||
],
|
||
"work_selection": (
|
||
"Before any issue or PR work, acquire or verify a work lease. Do not "
|
||
"code, review, branch, commit, push, comment, or open a PR until you "
|
||
"prove the target is not already being worked. Required checks: list "
|
||
"open PRs; search PRs linked to the issue; search local/remote "
|
||
"branches for the issue number; search worktrees for the issue "
|
||
"branch; check dirty worktrees; check active leases or recent "
|
||
"handoffs; check whether a merged PR already completed the issue. If "
|
||
"another session owns the lease, stop (continue as owner, review the "
|
||
"existing PR, hand off, request takeover after expiry, or report "
|
||
"'work already claimed'). Never create a parallel branch/PR unless "
|
||
"the old branch is proven abandoned and takeover is recorded. "
|
||
"gitea_lock_issue is the fail-closed author lease gate."),
|
||
"global_worktree": (
|
||
"Main checkout is a stable control checkout on master/main/dev only. "
|
||
"All LLM task work happens under branches/. Before any mutation, prove "
|
||
"project root, cwd, branch, main-checkout stable branch, and "
|
||
"session-owned branches/ worktree path. If cwd is not under "
|
||
"branches/, stop — no edit/create/delete/format/test-write/commit/"
|
||
"merge/rebase/checkout/cleanup, with no exceptions (docs, tests, "
|
||
"small fixes, review fixes, conflicts, emergencies). Main checkout: "
|
||
"read-only inspect, fetch, create worktrees, post-merge stable "
|
||
"update, explicit repair only."),
|
||
"shell_spawn_hard_stop": (
|
||
"A shell result of exit_code: -1 with empty stdout/stderr is an "
|
||
"executor spawn failure, not a command failure. Probe once (echo/pwd); "
|
||
"if the probe fails, mark shell unavailable for the session. After "
|
||
"two consecutive spawn failures, hard-stop all shell use — never "
|
||
"retry the same failing spawn — and emit a recovery report: restart "
|
||
"the session, kill hung background terminals, prefer MCP-native "
|
||
"paths (e.g. gitea_commit_files) for remaining mutations. Shell "
|
||
"unavailability never authorizes WebFetch/browser/manual-encoding "
|
||
"fallbacks (#258)."),
|
||
"subagent_tool_budget": (
|
||
"General-purpose subagents on deterministic MCP tasks must stay within "
|
||
"budget: single-step mutations (commit_files, create_pr, lock_issue) "
|
||
"max 15 tool calls / 5 min; review/merge queue inspection max 40 / "
|
||
"15 min. The main session with gitea.repo.commit must call "
|
||
"gitea_commit_files directly — no subagent delegation (#260). After "
|
||
"shell spawn failure (#258), attempt native MCP once; never authorize "
|
||
"WebFetch/Playwright/manual base64 when gitea_commit_files is "
|
||
"available. Never resume a failed subagent into a larger retry loop "
|
||
"(#259)."),
|
||
"subagent_delegation": (
|
||
"Deterministic write workflows (issue claim, branch creation, code "
|
||
"edits, commits, PR creation, review, merge, cleanup) run inline in "
|
||
"the parent session — subagents are blocked for them unless "
|
||
"explicitly allowed with a recorded justification. An authorized "
|
||
"write subagent must inherit the full gate context: issue lock, "
|
||
"branch/worktree path, identity/profile, allowed tool class, "
|
||
"command deny list, validation ledger requirement, and final report "
|
||
"schema. Subagent output is accepted only with the same proof "
|
||
"fields as the parent workflow; read-only delegation (search, "
|
||
"inventory, summarize) needs no explicit authorization. Enforced "
|
||
"fail closed via subagent_gate.assess_subagent_delegation and "
|
||
"validate_subagent_report (#266)."),
|
||
"review_merge_state_machine": (
|
||
"PR review/merge must follow the enforced state machine: PRECHECK → "
|
||
"INVENTORY → SELECT_PR → PIN_HEAD_SHA → CREATE_BRANCHES_WORKTREE → "
|
||
"VALIDATE → REVIEW_DECISION → APPROVE_OR_REQUEST_CHANGES → "
|
||
"PRE_MERGE_RECHECK → MERGE → POST_MERGE_REPORT. Failed upstream "
|
||
"states forbid downstream approve/merge. infra_stop and capability "
|
||
"blocks stop immediately. Blocked recovery handoffs must restart the "
|
||
"full workflow — never replay approve/merge commands (#290)."),
|
||
"native_mcp_preference": (
|
||
"Gitea mutations must use native MCP tools first. When MCP is "
|
||
"available, block shell scripts, direct API calls, WebFetch, "
|
||
"Playwright, helper encoders, and profile-secret reads unless "
|
||
"explicit recovery-mode proof is complete. If MCP transport is "
|
||
"broken or the shell circuit breaker trips, emit a terminal recovery "
|
||
"report instead of improvising unsafe fallback. Reviewers must not "
|
||
"touch or restart MCP server files/processes (#270)."),
|
||
}
|
||
|
||
_COMMON_WORKFLOWS = [
|
||
"task routing: resolve task capability via gitea_resolve_task_capability "
|
||
"to check required permission/role kind and identify the safe next action.",
|
||
"work selection: acquire or verify a work lease before any issue/PR work "
|
||
"(open PRs, linked PRs, branches, worktrees, dirty worktrees, leases, "
|
||
"merged completion); stop if another session owns the lease.",
|
||
"global worktree: main checkout on master/main/dev only; mutate only "
|
||
"from a branches/ worktree after proving root, cwd, branch, stable "
|
||
"main-checkout branch, and session worktree path.",
|
||
"issue authoring: verify identity, create/claim the issue, keep scope "
|
||
"explicit (remote/org/repo).",
|
||
"implementation: claim issue, branch from fresh master, implement only "
|
||
"the issue scope, test, open a PR referencing the issue, stop.",
|
||
"PR review: verify reviewer identity (must be a reviewer profile/namespace), "
|
||
"reconcile live state first (list_prs + view_pr selected PR immediately; "
|
||
"verify state/mergeable/head SHA/updated_at/merged info vs any prior reports; "
|
||
"git fetch --prune and pull --ff-only master; check linked issue state); "
|
||
"pin the head SHA; treat conflicting/stale reports as blocker and stop; "
|
||
"validate independently, post a verdict; never review your own work. "
|
||
"After merge, re-list and re-view.",
|
||
"PR merge: reviewer identity (must be a reviewer profile/namespace) + "
|
||
"eligibility check + pinned head + explicit 'MERGE PR <n>' confirmation, "
|
||
"only with operator authorization. Reconcile live state before deciding.",
|
||
]
|
||
|
||
# Skill registry (#128). status: 'available' = backed by tools in this
|
||
# server; 'external-mcp' = implemented in another MCP server that must be
|
||
# registered separately; 'designed-not-implemented' = design exists but no MCP
|
||
# tools yet; 'operator-only' = never performed by an LLM session.
|
||
_PROJECT_SKILLS = {
|
||
"gitea-issue-authoring": {
|
||
"description": "Create, view, label, claim, and close Gitea issues.",
|
||
"when_to_use": "Filing new work, updating issue state, claiming an "
|
||
"issue before implementation (gitea_mark_issue).",
|
||
"required_operations": ["gitea.read"],
|
||
"status": "available",
|
||
"notes": "Always pass remote/org/repo explicitly. Resolve task "
|
||
"capability first to confirm author profile and "
|
||
"gitea.issue.* permissions.",
|
||
"steps": [
|
||
"Resolve task first: gitea_resolve_task_capability to confirm "
|
||
"author namespace and required permissions (e.g. for "
|
||
"create_issue or comment_issue).",
|
||
"Verify identity with gitea_whoami (explicit remote).",
|
||
"Check the issue queue with gitea_list_issues.",
|
||
"Create with gitea_create_issue or claim with gitea_mark_issue "
|
||
"action='start'.",
|
||
"Keep issue bodies free of secrets, tokens, and raw service "
|
||
"URLs.",
|
||
"Release the claim (action='done') or close when finished.",
|
||
],
|
||
},
|
||
"gitea-pr-creation": {
|
||
"description": "Author a feature branch and open a pull request.",
|
||
"when_to_use": "After implementing a claimed issue on a feature "
|
||
"branch; author profiles only.",
|
||
"required_operations": ["gitea.pr.create"],
|
||
"status": "available",
|
||
"steps": [
|
||
"Resolve task first: gitea_resolve_task_capability(task='create_pr') "
|
||
"to confirm author profile.",
|
||
"Branch from fresh master (git pull first).",
|
||
"Implement only the claimed issue's scope; stage files "
|
||
"explicitly.",
|
||
"Run targeted tests, the full suite, py_compile, git diff "
|
||
"--check, and a secret sweep before committing.",
|
||
"Open the PR with gitea_create_pr referencing the issue "
|
||
"('Closes #<n>').",
|
||
"Stop: do not review or merge your own PR.",
|
||
],
|
||
},
|
||
"gitea-pr-review": {
|
||
"description": "Independently review a pull request and post a "
|
||
"verdict.",
|
||
"when_to_use": "Reviewer profile asked to evaluate someone else's "
|
||
"PR.",
|
||
"required_operations": ["gitea.pr.review"],
|
||
"status": "available",
|
||
"steps": [
|
||
"Resolve task first: gitea_resolve_task_capability(task='review_pr') "
|
||
"to confirm reviewer namespace and avoid author-profile blocks.",
|
||
"Load canonical workflow proof with gitea_load_review_workflow "
|
||
"before any review/merge mutation (#389).",
|
||
"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-reconcile-landed-pr": {
|
||
"description": "Reconcile open PRs whose heads are already on the "
|
||
"target branch without review or merge.",
|
||
"when_to_use": "An open PR blocks the review queue but its head SHA "
|
||
"is already an ancestor of master; use the dedicated "
|
||
"reconciliation path instead of review/merge.",
|
||
"required_operations": ["gitea.read"],
|
||
"status": "available",
|
||
"notes": "Load skills/llm-project-workflow/workflows/reconcile-landed-pr.md "
|
||
"first. PR close requires exact gitea.pr.close; review/merge "
|
||
"capabilities must not be used on this path.",
|
||
"steps": [
|
||
"Resolve task: gitea_resolve_task_capability(task='reconcile_landed_pr').",
|
||
"Load canonical workflow via mcp_get_skill_guide or "
|
||
"skills/llm-project-workflow/workflows/reconcile-landed-pr.md.",
|
||
"Scan queue: gitea_scan_already_landed_open_prs (pagination proof).",
|
||
"Assess one PR: gitea_assess_already_landed_reconciliation.",
|
||
"Follow the plan outcome: full close only with gitea.pr.close, "
|
||
"comment-then-stop when close is missing, recovery handoff otherwise.",
|
||
"Never approve, request changes, or merge on this path.",
|
||
],
|
||
},
|
||
"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.",
|
||
],
|
||
},
|
||
}
|
||
|
||
# Canonical workflow router skill names for Claude/Codex/Gemini parity (#551).
|
||
_PROJECT_SKILLS.update(workflow_skill.workflow_skill_registry_entries())
|
||
|
||
|
||
@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, #551).
|
||
|
||
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.
|
||
|
||
Includes the canonical workflow router under gitea-workflow,
|
||
llm-project-workflow, and git-pr-workflows (#551).
|
||
|
||
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"]
|
||
if meta.get("repo_path"):
|
||
entry["repo_path"] = meta["repo_path"]
|
||
if meta.get("canonical"):
|
||
entry["canonical"] = True
|
||
skills.append(entry)
|
||
return {"read_only": True, "count": len(skills), "skills": skills}
|
||
|
||
|
||
@mcp.tool()
|
||
def mcp_check_workflow_skill_preflight(
|
||
project_root: str | None = None,
|
||
) -> dict:
|
||
"""Fail-closed preflight for the canonical workflow skill wall (#551).
|
||
|
||
Verifies skills/llm-project-workflow/SKILL.md exists in the project tree
|
||
and reports whether Codex has a matching mount under ~/.codex/skills.
|
||
Call before git/Gitea mutations when the controller requires gitea-workflow.
|
||
|
||
Args:
|
||
project_root: Optional override; defaults to this MCP process root.
|
||
|
||
Returns:
|
||
dict with workflow_skill_ready, blocked, canonical_names, codex mount
|
||
status, and safe_next_action. Never secrets.
|
||
"""
|
||
root = (project_root or PROJECT_ROOT).strip()
|
||
result = workflow_skill.assess_workflow_skill_presence(root)
|
||
result["success"] = not result.get("blocked")
|
||
result["project_root"] = root
|
||
result["read_only"] = True
|
||
return result
|
||
|
||
|
||
@mcp.tool()
|
||
def mcp_get_skill_guide(skill_name: str) -> dict:
|
||
"""Step-by-step guide for one named project skill (#128).
|
||
|
||
Read-only; makes no API calls. Unknown skill names fail closed with
|
||
the list of valid names instead of guessing.
|
||
|
||
Args:
|
||
skill_name: A name from mcp_list_project_skills (case-insensitive).
|
||
|
||
Returns:
|
||
dict with 'success', 'skill' metadata, and 'steps'; on an unknown
|
||
name, 'success' False with 'reasons' and 'valid_skills'.
|
||
"""
|
||
key = (skill_name or "").strip().lower()
|
||
meta = _PROJECT_SKILLS.get(key)
|
||
if meta is None:
|
||
return {
|
||
"success": False,
|
||
"reasons": [f"unknown skill '{skill_name}' (fail closed)"],
|
||
"valid_skills": sorted(_PROJECT_SKILLS),
|
||
}
|
||
skill = {
|
||
"name": key,
|
||
"description": meta["description"],
|
||
"when_to_use": meta["when_to_use"],
|
||
"required_operations": meta["required_operations"],
|
||
"status": meta["status"],
|
||
}
|
||
if meta.get("notes"):
|
||
skill["notes"] = meta["notes"]
|
||
return {"success": True, "skill": skill, "steps": list(meta["steps"])}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_whoami(
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
) -> dict:
|
||
"""Look up the Gitea account the MCP server is authenticated as.
|
||
|
||
Read-only. Calls Gitea's authenticated-user endpoint (GET /api/v1/user)
|
||
with the configured token and returns safe identity metadata only. Use
|
||
this to prove which account a mutating workflow (e.g. review/merge) would
|
||
act as, so self-review/self-merge can be detected before acting.
|
||
|
||
Never returns the token, Authorization header, password, or any other
|
||
secret material. Fails closed with a clear error if the identity cannot
|
||
be determined.
|
||
|
||
Args:
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
|
||
Returns:
|
||
dict with 'authenticated', 'username', 'display_name', 'user_id',
|
||
'email', 'server', 'remote', and 'profile' (safe runtime profile
|
||
metadata: profile_name + allowed_operations; never the token).
|
||
"""
|
||
record_preflight_check("whoami")
|
||
if remote not in REMOTES:
|
||
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
||
h = host or REMOTES[remote]["host"]
|
||
auth = _auth(h)
|
||
url = gitea_url(h, "/api/v1/user")
|
||
data = api_request("GET", url, auth)
|
||
if not data or not data.get("login"):
|
||
# Fail closed: never assume an identity we could not verify.
|
||
raise RuntimeError(
|
||
f"Could not determine the authenticated Gitea identity for {h}. "
|
||
"Verify the configured token is valid for this instance."
|
||
)
|
||
# Runtime profile metadata is non-secret (name + allowed op categories).
|
||
# The token is resolved separately and is never included here. Endpoint
|
||
# URLs stay out of normal LLM-facing output (#120): the logical remote
|
||
# name is the addressing surface; 'server' appears only under the
|
||
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in.
|
||
profile = get_profile()
|
||
result = {
|
||
"authenticated": True,
|
||
"username": data.get("login"),
|
||
"display_name": data.get("full_name") or None,
|
||
"user_id": data.get("id"),
|
||
"email": data.get("email") or None,
|
||
"remote": remote,
|
||
"profile": {
|
||
"profile_name": profile["profile_name"],
|
||
"allowed_operations": profile["allowed_operations"],
|
||
"forbidden_operations": profile["forbidden_operations"],
|
||
"environment": profile.get("environment"),
|
||
"service": profile.get("service"),
|
||
"identity": profile.get("identity"),
|
||
"role": profile.get("role"),
|
||
"profile_address": profile.get("profile_path"),
|
||
"execution_profile": profile.get("execution_profile"),
|
||
"audit_label": profile.get("audit_label"),
|
||
"auth_source_type": profile.get("auth_source_type"),
|
||
},
|
||
}
|
||
if _reveal_endpoints():
|
||
result["server"] = gitea_url(h, "").rstrip("/")
|
||
return result
|
||
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_get_authenticated_user(
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
) -> dict:
|
||
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
|
||
return gitea_whoami(remote=remote, host=host)
|
||
|
||
@mcp.tool()
|
||
def gitea_get_current_user(
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
) -> dict:
|
||
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
|
||
return gitea_whoami(remote=remote, host=host)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_get_profile(
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
resolve_identity: bool = True,
|
||
) -> dict:
|
||
"""Describe the active Gitea MCP execution profile for this runtime.
|
||
|
||
Read-only. Reports the non-secret configuration of the running MCP
|
||
process (profile name, allowed/forbidden operation categories, audit
|
||
label, auth *status*). Endpoint URLs and token source names are hidden
|
||
from normal output (#120) and appear only under the
|
||
GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Optionally resolves the
|
||
authenticated username via ``gitea_whoami``'s endpoint so an LLM can see
|
||
who this runtime acts as.
|
||
|
||
This tool never mutates Gitea and never approves, merges, comments, or
|
||
creates anything. It never returns the token value, Authorization header,
|
||
password, raw environment, or credential file paths. Identity resolution
|
||
fails soft: if it cannot be determined, ``authenticated_username`` is null
|
||
and ``identity_status`` marks it, but the profile config is still returned.
|
||
|
||
Args:
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
resolve_identity: If True, attempt a read-only identity lookup.
|
||
|
||
Returns:
|
||
dict of safe profile metadata. ``identity_status`` is one of
|
||
'verified', 'unknown', 'unavailable', or 'not_resolved'.
|
||
"""
|
||
profile = get_profile()
|
||
reveal = _reveal_endpoints()
|
||
result = {
|
||
"profile_name": profile["profile_name"],
|
||
"allowed_operations": profile["allowed_operations"],
|
||
"forbidden_operations": profile["forbidden_operations"],
|
||
"audit_label": profile["audit_label"],
|
||
"environment": profile.get("environment"),
|
||
"service": profile.get("service"),
|
||
"identity": profile.get("identity"),
|
||
"role": profile.get("role"),
|
||
"profile_address": profile.get("profile_path"),
|
||
"execution_profile": profile.get("execution_profile"),
|
||
"auth_source_type": profile.get("auth_source_type"),
|
||
# Auth is reported as a status only (#120): the token source *name*
|
||
# (env var name / keychain id) joins endpoint URLs behind the
|
||
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Token values never appear.
|
||
"auth_status": ("configured" if profile["token_source_name"]
|
||
else "unconfigured"),
|
||
"remote": remote if remote in REMOTES else None,
|
||
"authenticated_username": None,
|
||
"identity_status": "not_resolved",
|
||
}
|
||
if reveal:
|
||
result["token_source_name"] = profile["token_source_name"]
|
||
result["base_url"] = profile["base_url"]
|
||
result["server"] = None
|
||
|
||
if remote not in REMOTES:
|
||
# Mark ambiguity rather than raising: the tool stays inspectable.
|
||
result["identity_status"] = "unknown"
|
||
result["remote_error"] = f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}"
|
||
return result
|
||
|
||
h = host or REMOTES[remote]["host"]
|
||
if reveal:
|
||
result["server"] = gitea_url(h, "").rstrip("/")
|
||
|
||
if resolve_identity:
|
||
try:
|
||
auth = _auth(h)
|
||
data = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
|
||
login = (data or {}).get("login")
|
||
if login:
|
||
result["authenticated_username"] = login
|
||
result["identity_status"] = "verified"
|
||
else:
|
||
result["identity_status"] = "unknown"
|
||
except Exception:
|
||
# Fail soft for the identity field only. Never surface the error
|
||
# detail or any credential material — just mark it unavailable.
|
||
result["identity_status"] = "unavailable"
|
||
|
||
return result
|
||
|
||
|
||
_RUNTIME_CAPABILITY_TASKS = (
|
||
"create_issue",
|
||
"comment_issue",
|
||
"create_pr",
|
||
"work_issue",
|
||
"review_pr",
|
||
"merge_pr",
|
||
"close_issue",
|
||
"reconcile_already_landed_pr",
|
||
)
|
||
|
||
|
||
def _matching_configured_profiles(
|
||
config: dict | None,
|
||
required_permission: str,
|
||
) -> list[str]:
|
||
"""Profile names that allow *required_permission* (redacted metadata only)."""
|
||
if not config or "profiles" not in config:
|
||
return []
|
||
matches: list[str] = []
|
||
for p_name, p_data in config["profiles"].items():
|
||
if not p_data.get("enabled", True):
|
||
continue
|
||
p_allowed = p_data.get("allowed_operations") or []
|
||
p_forbidden = p_data.get("forbidden_operations") or []
|
||
p_allowed_n = []
|
||
for op in p_allowed:
|
||
try:
|
||
p_allowed_n.append(gitea_config.normalize_operation(op))
|
||
except Exception:
|
||
pass
|
||
p_forbidden_n = []
|
||
for op in p_forbidden:
|
||
try:
|
||
p_forbidden_n.append(gitea_config.normalize_operation(op))
|
||
except Exception:
|
||
pass
|
||
ok, _ = gitea_config.check_operation(
|
||
required_permission, p_allowed_n, p_forbidden_n
|
||
)
|
||
if ok:
|
||
matches.append(p_name)
|
||
return sorted(matches)
|
||
|
||
|
||
def _build_runtime_task_capabilities(
|
||
allowed: list[str],
|
||
forbidden: list[str],
|
||
config: dict | None,
|
||
) -> dict:
|
||
"""Per-task capability summary for role-aware runtime context (#139)."""
|
||
task_entries = []
|
||
flags: dict[str, bool] = {}
|
||
flag_keys = {
|
||
"create_issue": "can_create_issues",
|
||
"comment_issue": "can_comment_on_issues",
|
||
"create_pr": "can_author_prs",
|
||
"work_issue": "can_work_issues",
|
||
"review_pr": "can_review_prs",
|
||
"merge_pr": "can_merge_prs",
|
||
"close_issue": "can_close_issues",
|
||
"reconcile_already_landed_pr": "can_reconcile_already_landed_prs",
|
||
}
|
||
for task in _RUNTIME_CAPABILITY_TASKS:
|
||
permission = task_capability_map.required_permission(task)
|
||
allowed_here, _ = gitea_config.check_operation(
|
||
permission, allowed, forbidden
|
||
)
|
||
entry = {
|
||
"task": task,
|
||
"required_permission": permission,
|
||
"required_role_kind": task_capability_map.required_role(task),
|
||
"allowed_in_current_session": allowed_here,
|
||
"matching_configured_profiles": _matching_configured_profiles(
|
||
config, permission
|
||
),
|
||
}
|
||
task_entries.append(entry)
|
||
flag_name = flag_keys.get(task)
|
||
if flag_name:
|
||
flags[flag_name] = allowed_here
|
||
return {
|
||
**flags,
|
||
"issue_comment_not_implied_by_pr_comment": True,
|
||
"task_capabilities": task_entries,
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_assess_review_merge_state_machine(
|
||
target_state: str | None = None,
|
||
state_completion: dict[str, bool] | None = None,
|
||
pre_merge_gates: dict[str, bool] | None = None,
|
||
infra_stop: bool = False,
|
||
capability_blocked: bool = False,
|
||
recovery_handoff_text: str | None = None,
|
||
final_report_text: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: assess enforced PR review/merge workflow state (#290)."""
|
||
completion = state_completion or {}
|
||
blockers = review_merge_state_machine.assess_workflow_blockers(
|
||
infra_stop=infra_stop,
|
||
capability_blocked=capability_blocked,
|
||
)
|
||
result = {
|
||
"workflow": review_merge_state_machine.workflow_status(
|
||
completion,
|
||
infra_stop=infra_stop,
|
||
capability_blocked=capability_blocked,
|
||
),
|
||
"blockers": blockers,
|
||
"approve": review_merge_state_machine.can_approve(
|
||
completion,
|
||
infra_stop=infra_stop,
|
||
capability_blocked=capability_blocked,
|
||
),
|
||
"merge": review_merge_state_machine.can_merge(
|
||
completion,
|
||
pre_merge_gates=pre_merge_gates,
|
||
infra_stop=infra_stop,
|
||
capability_blocked=capability_blocked,
|
||
),
|
||
}
|
||
if target_state:
|
||
result["advancement"] = review_merge_state_machine.assess_state_advancement(
|
||
completion,
|
||
target_state=target_state,
|
||
infra_stop=infra_stop,
|
||
capability_blocked=capability_blocked,
|
||
)
|
||
if recovery_handoff_text is not None:
|
||
result["recovery_handoff"] = (
|
||
review_merge_state_machine.assess_blocked_recovery_handoff(
|
||
recovery_handoff_text
|
||
)
|
||
)
|
||
if final_report_text is not None:
|
||
result["final_report_claims"] = (
|
||
review_merge_state_machine.assess_final_report_state_claims(
|
||
final_report_text,
|
||
state_completion=completion,
|
||
approve_completed=bool(completion.get("APPROVE_OR_REQUEST_CHANGES")),
|
||
merge_completed=bool(completion.get("MERGE")),
|
||
)
|
||
)
|
||
return result
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_assess_gitea_operation_path(
|
||
task: str,
|
||
path_kind: str | None = None,
|
||
command_or_detail: str | None = None,
|
||
mcp_available: bool = True,
|
||
mcp_tool_visible: bool = True,
|
||
recovery_mode: bool = False,
|
||
recovery_proof_complete: bool = False,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: assess whether a proposed Gitea mutation path is allowed (#270).
|
||
|
||
Native MCP must be preferred when available. Shell scripts, direct API
|
||
calls, WebFetch, Playwright, and helper encoders are blocked unless
|
||
explicit recovery-mode proof is supplied.
|
||
"""
|
||
profile = get_profile()
|
||
role = _role_kind(
|
||
profile.get("allowed_operations") or [],
|
||
profile.get("forbidden_operations") or [],
|
||
)
|
||
return native_mcp_preference.assess_gitea_operation_path(
|
||
task=task,
|
||
path_kind=path_kind,
|
||
command_or_detail=command_or_detail,
|
||
mcp_available=mcp_available,
|
||
mcp_tool_visible=mcp_tool_visible,
|
||
recovery_mode=recovery_mode,
|
||
recovery_proof_complete=recovery_proof_complete,
|
||
role=role,
|
||
active_profile=profile.get("profile_name"),
|
||
)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_record_shell_spawn_outcome(
|
||
exit_code: int | None = None,
|
||
stdout: str = "",
|
||
stderr: str = "",
|
||
spawn_failure: bool | None = None,
|
||
probe_attempted: bool = False,
|
||
probe_succeeded: bool | None = None,
|
||
) -> dict:
|
||
"""Record shell spawn outcome and update the session circuit breaker (#270)."""
|
||
return native_mcp_preference.record_shell_spawn_outcome(
|
||
exit_code=exit_code,
|
||
stdout=stdout,
|
||
stderr=stderr,
|
||
spawn_failure=spawn_failure,
|
||
probe_attempted=probe_attempted,
|
||
probe_succeeded=probe_succeeded,
|
||
)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_get_shell_health() -> dict:
|
||
"""Read-only: return shell spawn circuit-breaker state for this MCP session (#270)."""
|
||
return native_mcp_preference.shell_health_status()
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_validate_review_final_report(
|
||
report_text: str,
|
||
review_decision_lock: dict | None = None,
|
||
linked_issue_lock: dict | None = None,
|
||
validation_report: dict | None = None,
|
||
action_log: list[dict] | None = None,
|
||
mutations_observed: bool = False,
|
||
local_edits: bool = False,
|
||
validation_session: dict | None = None,
|
||
) -> dict:
|
||
"""Read-only: machine-validate reviewer final report schema before output (#391).
|
||
|
||
Composes the composable final-report validator with review-specific schema
|
||
gates (legacy fields, mutation categories, merge/issue claims, blocked
|
||
handoff replay, and narrative/handoff consistency).
|
||
"""
|
||
from review_final_report_schema import assess_review_final_report_schema
|
||
|
||
return assess_review_final_report_schema(
|
||
report_text,
|
||
review_decision_lock=review_decision_lock,
|
||
linked_issue_lock=linked_issue_lock,
|
||
validation_report=validation_report,
|
||
action_log=action_log,
|
||
mutations_observed=mutations_observed,
|
||
local_edits=local_edits,
|
||
validation_session=validation_session,
|
||
)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_get_runtime_context(
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
worktree_path: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: explicit visibility into active profile, configuration model, and eligibility.
|
||
|
||
Reports config model shape, profile mode (static vs dynamic), and detailed blocks.
|
||
|
||
Args:
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
"""
|
||
profile = get_profile()
|
||
config = gitea_config.load_config()
|
||
reveal = _reveal_endpoints()
|
||
|
||
# Determine config model/version
|
||
if config is None:
|
||
config_model = "env-only"
|
||
elif config.get("version") == 1:
|
||
config_model = "v1"
|
||
elif config.get("version") == 2:
|
||
if "contexts" in config or config.get("shape") == "contexts":
|
||
config_model = "v2-contexts"
|
||
else:
|
||
config_model = "v2-environments"
|
||
else:
|
||
config_model = f"unknown-version-{config.get('version')}"
|
||
|
||
# Determine profile source
|
||
if os.environ.get("GITEA_PROFILE_NAME"):
|
||
profile_source = "env var"
|
||
elif gitea_config.selected_profile_name():
|
||
profile_source = "config file profile"
|
||
else:
|
||
profile_source = "default"
|
||
|
||
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
||
username = _authenticated_username(h) if h else None
|
||
|
||
switching = gitea_config.is_runtime_switching_enabled()
|
||
profile_mode = "dynamic-profile" if switching else "static-profile"
|
||
|
||
# Evaluate review/merge eligibility
|
||
allowed = profile["allowed_operations"] or []
|
||
forbidden = profile["forbidden_operations"] or []
|
||
|
||
# Check approve
|
||
approve_ok, _ = gitea_config.check_operation("approve", allowed, forbidden)
|
||
# Check merge
|
||
merge_ok, _ = gitea_config.check_operation("merge", allowed, forbidden)
|
||
|
||
review_merge_allowed = False
|
||
blocked_reasons = []
|
||
suggested_fix = "none"
|
||
|
||
if not username:
|
||
blocked_reasons.append("Authenticated identity is unresolved. Gitea credentials are missing or invalid.")
|
||
suggested_fix = "operator action"
|
||
elif not (approve_ok or merge_ok):
|
||
blocked_reasons.append(
|
||
f"Active profile '{profile['profile_name']}' does not permit review or merge operations."
|
||
)
|
||
if switching:
|
||
suggested_fix = "profile switch"
|
||
else:
|
||
suggested_fix = "reviewer namespace"
|
||
else:
|
||
review_merge_allowed = True
|
||
|
||
# Note that self-review and self-merge are blocked regardless of profile roles
|
||
blocked_reasons.append(
|
||
"Note: self-review and self-merge are always blocked at runtime for any PR authored by the active user."
|
||
)
|
||
|
||
safe_next_action = "None; ready for operations."
|
||
if suggested_fix == "operator action":
|
||
safe_next_action = f"Ask the operator to configure valid credentials/token for profile '{profile['profile_name']}'."
|
||
elif suggested_fix == "profile switch":
|
||
safe_next_action = "Switch to a reviewer profile by calling gitea_activate_profile with a reviewer profile."
|
||
elif suggested_fix == "reviewer namespace":
|
||
safe_next_action = (
|
||
"Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, "
|
||
"or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
||
)
|
||
|
||
session_capabilities = _build_runtime_task_capabilities(
|
||
allowed, forbidden, config
|
||
)
|
||
|
||
preflight = assess_preflight_status(worktree_path)
|
||
if not preflight["preflight_ready"]:
|
||
safe_next_action = (
|
||
"Complete pre-flight verification before mutating: call gitea_whoami, then "
|
||
"gitea_resolve_task_capability for the intended task. "
|
||
f"Blocked: {'; '.join(preflight['preflight_block_reasons'])}"
|
||
)
|
||
|
||
result = {
|
||
"active_profile": profile["profile_name"],
|
||
"authenticated_username": username,
|
||
"remote": remote if remote in REMOTES else None,
|
||
"config_model": config_model,
|
||
"profile_source": profile_source,
|
||
"allowed_operations": allowed,
|
||
"forbidden_operations": forbidden,
|
||
"runtime_switching_supported": switching,
|
||
"profile_mode": profile_mode,
|
||
"review_merge_allowed": review_merge_allowed,
|
||
"review_merge_blocked_reasons": blocked_reasons,
|
||
"suggested_fix": suggested_fix,
|
||
"safe_next_action": safe_next_action,
|
||
"preflight_ready": preflight["preflight_ready"],
|
||
"preflight_block_reasons": preflight["preflight_block_reasons"],
|
||
"preflight_workspace": preflight.get("preflight_workspace"),
|
||
"session_capabilities": session_capabilities,
|
||
"reconciler_profile_assessment": reconciler_profile.assess_reconciler_profile(
|
||
allowed, forbidden
|
||
),
|
||
"role_kind": _role_kind(allowed, forbidden),
|
||
"shell_health": native_mcp_preference.shell_health_status(),
|
||
"workflow_load_proof": review_workflow_load.workflow_load_status(
|
||
PROJECT_ROOT),
|
||
}
|
||
|
||
if reveal and h:
|
||
result["server"] = gitea_url(h, "").rstrip("/")
|
||
|
||
return result
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_record_pre_review_command(
|
||
command: str,
|
||
cwd: str | None = None,
|
||
classification: str | None = None,
|
||
) -> dict:
|
||
"""Classify and record a command executed before workflow load (#403).
|
||
|
||
Read-only with respect to Gitea API. Pre-review inventory/diagnostic commands
|
||
may be recorded as allowed; boundary violations block reviewer mutations.
|
||
"""
|
||
recorded = review_workflow_boundary.record_pre_review_command(
|
||
command,
|
||
cwd=cwd,
|
||
project_root=PROJECT_ROOT,
|
||
classification=classification,
|
||
)
|
||
boundary_state = review_workflow_boundary.assess_boundary_status(PROJECT_ROOT)
|
||
return {
|
||
"success": True,
|
||
"recorded": recorded,
|
||
"boundary_status": boundary_state.get("boundary_status"),
|
||
"boundary_clean": boundary_state.get("boundary_clean"),
|
||
"reasons": list(boundary_state.get("reasons") or []),
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_load_review_workflow(
|
||
prompt_text: str | None = None,
|
||
) -> dict:
|
||
"""Load and record canonical review-merge workflow proof for this session (#389, #403).
|
||
|
||
Read-only with respect to Gitea API; records in-process workflow source/hash
|
||
proof and session boundary state required before reviewer review or merge
|
||
mutations.
|
||
"""
|
||
try:
|
||
recorded = review_workflow_load.record_review_workflow_load(
|
||
PROJECT_ROOT, prompt_text=prompt_text)
|
||
except OSError as exc:
|
||
return {
|
||
"success": False,
|
||
"loaded": False,
|
||
"reasons": [str(exc)],
|
||
"recovery_handoff": review_workflow_load.recovery_handoff_without_replay(),
|
||
}
|
||
boundary_reasons = review_workflow_boundary.boundary_blockers(PROJECT_ROOT)
|
||
helper = review_workflow_boundary.workflow_load_helper_result(
|
||
recorded, PROJECT_ROOT)
|
||
return {
|
||
"success": not boundary_reasons,
|
||
"loaded": True,
|
||
"workflow_source": recorded["workflow_source"],
|
||
"task_mode": recorded["task_mode"],
|
||
"workflow_hash": recorded["workflow_hash"],
|
||
"workflow_version": recorded["workflow_version"],
|
||
"final_report_schema_path": recorded["final_report_schema_path"],
|
||
"final_report_schema_hash": recorded["final_report_schema_hash"],
|
||
"prompt_conflicts_with_workflow": recorded[
|
||
"prompt_conflicts_with_workflow"],
|
||
"prompt_conflict_reasons": recorded.get("prompt_conflict_reasons") or [],
|
||
"workflow_load_proof_present": True,
|
||
"boundary_status": recorded.get("boundary_status"),
|
||
"boundary_clean": recorded.get("boundary_clean"),
|
||
"workflow_load_helper_result": helper,
|
||
"reasons": boundary_reasons,
|
||
"recovery_handoff": (
|
||
review_workflow_load.recovery_handoff_without_replay()
|
||
if boundary_reasons else []
|
||
),
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_list_profiles() -> dict:
|
||
"""Read-only: list all Gitea MCP profiles with redacted metadata.
|
||
|
||
Exposes names, role kinds, enabled state, allowed/forbidden operations, and active status.
|
||
Token values, keychain IDs, and raw URLs are hidden unless GITEA_MCP_REVEAL_ENDPOINTS=1 is enabled.
|
||
"""
|
||
config = gitea_config.load_config()
|
||
reveal = _reveal_endpoints()
|
||
active_name = gitea_config.selected_profile_name() or get_profile()["profile_name"]
|
||
|
||
profiles_out = []
|
||
|
||
if config is None:
|
||
# Env-only: return the active profile
|
||
p = get_profile()
|
||
role = _role_kind(p["allowed_operations"], p["forbidden_operations"])
|
||
h = REMOTES.get("dadeschools", {}).get("host") # default host
|
||
username = _authenticated_username(h) if h else None
|
||
|
||
prof = {
|
||
"name": p["profile_name"],
|
||
"role_kind": role,
|
||
"allowed_operations": p["allowed_operations"],
|
||
"forbidden_operations": p["forbidden_operations"],
|
||
"identity_status": "verified" if username else "unresolved",
|
||
"is_active": True,
|
||
"enabled": True,
|
||
}
|
||
if reveal:
|
||
prof["token_source_name"] = p["token_source_name"]
|
||
prof["base_url"] = p["base_url"]
|
||
profiles_out.append(prof)
|
||
else:
|
||
# Load from config profiles
|
||
profiles_dict = config.get("profiles") or {}
|
||
unavailable_dict = config.get("unavailable") or {}
|
||
audit_only_profiles = config.get("audit_only_profiles") or {}
|
||
|
||
# First, process available profiles
|
||
for name, p in profiles_dict.items():
|
||
role = _role_kind(p.get("allowed_operations", []), p.get("forbidden_operations", []))
|
||
is_active = (name == active_name)
|
||
|
||
# Identity status lookup
|
||
if is_active:
|
||
h = REMOTES.get("dadeschools", {}).get("host") # default host
|
||
username = _authenticated_username(h) if h else None
|
||
identity_status = "verified" if username else "unresolved"
|
||
else:
|
||
# Safely resolve if credentials are present without networking
|
||
try:
|
||
tok = gitea_config.resolve_token(p)
|
||
identity_status = "credentials present" if tok else "missing credentials"
|
||
except Exception:
|
||
identity_status = "missing credentials"
|
||
|
||
prof = {
|
||
"name": name,
|
||
"role_kind": role,
|
||
"allowed_operations": p.get("allowed_operations", []),
|
||
"forbidden_operations": p.get("forbidden_operations", []),
|
||
"identity_status": identity_status,
|
||
"is_active": is_active,
|
||
"enabled": p.get("enabled", True),
|
||
}
|
||
if reveal:
|
||
if isinstance(p.get("auth"), dict):
|
||
prof["auth"] = p["auth"]
|
||
prof["base_url"] = p.get("base_url")
|
||
else:
|
||
if isinstance(p.get("auth"), dict):
|
||
prof["auth"] = {k: ("<redacted>" if k != "type" else v) for k, v in p["auth"].items()}
|
||
prof["base_url"] = "<redacted>"
|
||
profiles_out.append(prof)
|
||
|
||
# Process unavailable/disabled/audit-only profiles
|
||
for name, p in audit_only_profiles.items():
|
||
role = _role_kind(p.get("allowed_operations", []), p.get("forbidden_operations", []))
|
||
is_active = (name == active_name)
|
||
prof = {
|
||
"name": name,
|
||
"role_kind": role,
|
||
"allowed_operations": p.get("allowed_operations", []),
|
||
"forbidden_operations": p.get("forbidden_operations", []),
|
||
"identity_status": "unavailable (disabled)",
|
||
"is_active": is_active,
|
||
"enabled": p.get("enabled", True),
|
||
"unavailable_reason": p.get("_unavailable_reason"),
|
||
}
|
||
if reveal:
|
||
if isinstance(p.get("auth"), dict):
|
||
prof["auth"] = p["auth"]
|
||
prof["base_url"] = p.get("base_url")
|
||
else:
|
||
if isinstance(p.get("auth"), dict):
|
||
prof["auth"] = {k: ("<redacted>" if k != "type" else v) for k, v in p["auth"].items()}
|
||
prof["base_url"] = "<redacted>"
|
||
profiles_out.append(prof)
|
||
|
||
# Handle unavailable mappings that are not in audit_only
|
||
for name, reason in unavailable_dict.items():
|
||
if not any(x["name"] == name for x in profiles_out):
|
||
profiles_out.append({
|
||
"name": name,
|
||
"role_kind": "limited",
|
||
"allowed_operations": [],
|
||
"forbidden_operations": [],
|
||
"identity_status": "unavailable",
|
||
"is_active": (name == active_name),
|
||
"enabled": False,
|
||
"unavailable_reason": reason,
|
||
})
|
||
|
||
return {"profiles": profiles_out}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_activate_profile(
|
||
profile_name: str,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
) -> dict:
|
||
"""Gated profile activation. Switch the active profile in-place for this runtime session.
|
||
|
||
Only allowed if "allow_runtime_switching": true is explicitly configured.
|
||
|
||
Args:
|
||
profile_name: Profile to activate.
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
"""
|
||
if not gitea_config.is_runtime_switching_enabled():
|
||
return {
|
||
"success": False,
|
||
"message": (
|
||
"Runtime profile switching is disabled in this configuration. Static profile mode is active. "
|
||
"To enable switching, set 'allow_runtime_switching': true in the config rules."
|
||
)
|
||
}
|
||
|
||
config = gitea_config.load_config()
|
||
if not config:
|
||
return {
|
||
"success": False,
|
||
"message": "No profiles configuration file is loaded. Switching is not supported in env-only mode."
|
||
}
|
||
|
||
profiles_dict = config.get("profiles") or {}
|
||
if profile_name not in profiles_dict:
|
||
return {
|
||
"success": False,
|
||
"message": f"Profile '{profile_name}' not found in loaded config."
|
||
}
|
||
|
||
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
||
|
||
# 1. Record before state
|
||
before_profile = get_profile()["profile_name"]
|
||
before_identity = _authenticated_username(h) if h else None
|
||
|
||
# 2. Perform switch
|
||
gitea_config._active_profile_override = profile_name
|
||
|
||
# 3. Clear identity cache to force a fresh verification
|
||
if h:
|
||
_IDENTITY_CACHE.pop(h, None)
|
||
|
||
# 4. Resolve fresh identity
|
||
after_profile = get_profile()["profile_name"]
|
||
after_identity = _authenticated_username(h) if h else None
|
||
|
||
# 4.5 Record the authorized pivot in the in-process mutation authority
|
||
# and keep the session profile lock in sync — this is the ONLY path that
|
||
# may authorize an author→reviewer role pivot.
|
||
if _MUTATION_AUTHORITY is not None:
|
||
_MUTATION_AUTHORITY["current_profile"] = after_profile
|
||
_MUTATION_AUTHORITY["current_identity"] = after_identity
|
||
_MUTATION_AUTHORITY["role_pivot_authorized"] = True
|
||
_MUTATION_AUTHORITY["role_pivot_record"] = {
|
||
"from_profile": before_profile,
|
||
"to_profile": after_profile,
|
||
"from_identity": before_identity,
|
||
"to_identity": after_identity,
|
||
}
|
||
if os.environ.get(SESSION_PROFILE_LOCK_ENV) and after_profile:
|
||
os.environ[SESSION_PROFILE_LOCK_ENV] = after_profile
|
||
|
||
# 5. Audit the switch if auditing is on
|
||
_audit(
|
||
"activate_profile",
|
||
host=h,
|
||
remote=remote,
|
||
result={"success": True, "before": before_profile, "after": after_profile},
|
||
username=after_identity,
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).",
|
||
"before_profile": before_profile,
|
||
"before_identity": before_identity,
|
||
"after_profile": after_profile,
|
||
"after_identity": after_identity,
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_audit_config() -> dict:
|
||
"""Audit the configured profiles/services: enabled state, no secrets.
|
||
|
||
Read-only and local-only: loads the canonical profiles.json named by
|
||
GITEA_MCP_CONFIG and reports profile/service names, contexts, enabled
|
||
state, capabilities, auth *status*, and one-line service summaries (e.g.
|
||
``PRGS Jenkins: enabled, read-only, authenticated``). Disabled entries
|
||
are listed so they can be audited, but the server refuses to act with
|
||
them and never falls back to another profile or service.
|
||
|
||
Never includes endpoint URLs, keychain ids, token source names, or token
|
||
values. Endpoint-revealing diagnostics exist only in the local admin CLI
|
||
(``python3 gitea_config.py audit --reveal-endpoints``), never over MCP.
|
||
"""
|
||
config = gitea_config.load_config()
|
||
if config is None:
|
||
return {
|
||
"configured": False,
|
||
"message": "No GITEA_MCP_CONFIG configured; env-only mode.",
|
||
}
|
||
report = gitea_config.audit_config(config)
|
||
report["configured"] = True
|
||
report["summaries"] = gitea_config.service_summaries(config)
|
||
return report
|
||
|
||
|
||
def _post_structured_issue_comment(
|
||
*,
|
||
issue_number: int,
|
||
body: str,
|
||
remote: str,
|
||
host: str | None,
|
||
org: str | None,
|
||
repo: str | None,
|
||
audit_op: str = "create_issue_comment",
|
||
) -> dict:
|
||
"""Post an issue-thread comment after permission gates already passed."""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||
with _audited(
|
||
audit_op,
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
issue_number=issue_number,
|
||
request_metadata={"body_chars": len(body)},
|
||
):
|
||
data = api_request("POST", api, auth, {"body": body})
|
||
result = {
|
||
"success": True,
|
||
"performed": True,
|
||
"comment_id": data["id"],
|
||
"issue_number": issue_number,
|
||
}
|
||
if _reveal_endpoints():
|
||
result["url"] = data.get("html_url")
|
||
return result
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_mark_issue(
|
||
issue_number: int,
|
||
action: str,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
worktree_path: str | None = None,
|
||
branch_name: str | None = None,
|
||
profile: str | None = None,
|
||
) -> dict:
|
||
"""Claim or release an issue via the status:in-progress label.
|
||
|
||
This is the cross-agent lock mechanism. Check before starting work.
|
||
|
||
Args:
|
||
issue_number: The issue number.
|
||
action: 'start' to claim (add label) or 'done' to release (remove label).
|
||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
worktree_path: Active task worktree to inspect for pre-flight purity.
|
||
|
||
Returns:
|
||
dict with 'success' boolean and 'message'.
|
||
"""
|
||
if action not in ("start", "done"):
|
||
raise ValueError(f"action must be 'start' or 'done', got '{action}'")
|
||
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("mark_issue"))
|
||
if blocked:
|
||
return blocked
|
||
verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue")
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = repo_api_url(h, o, r)
|
||
|
||
# Find the status:in-progress label id
|
||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||
label_id = None
|
||
for lb in labels:
|
||
if lb["name"] == "status:in-progress":
|
||
label_id = lb["id"]
|
||
break
|
||
|
||
if label_id is None:
|
||
raise RuntimeError(
|
||
"Label 'status:in-progress' not found. "
|
||
"Run manage_labels.py to create it first."
|
||
)
|
||
|
||
if action == "start":
|
||
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
|
||
issue_number=issue_number,
|
||
request_metadata={"op": "add", "label": "status:in-progress"}):
|
||
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
|
||
{"labels": [label_id]})
|
||
active_profile = (profile or get_profile().get("profile_name") or "unknown")
|
||
branch = (branch_name or "pending").strip() or "pending"
|
||
heartbeat_body = issue_claim_heartbeat.format_heartbeat_body(
|
||
kind="claim",
|
||
issue_number=issue_number,
|
||
branch=branch,
|
||
phase="claimed",
|
||
profile=active_profile,
|
||
next_action="create worktree and begin implementation",
|
||
)
|
||
heartbeat = _post_structured_issue_comment(
|
||
issue_number=issue_number,
|
||
body=heartbeat_body,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
audit_op="claim_heartbeat",
|
||
)
|
||
return {
|
||
"success": True,
|
||
"message": f"Issue #{issue_number} claimed.",
|
||
"heartbeat_posted": heartbeat.get("success", False),
|
||
"heartbeat_comment_id": heartbeat.get("comment_id"),
|
||
}
|
||
else:
|
||
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
|
||
issue_number=issue_number,
|
||
request_metadata={"op": "remove", "label": "status:in-progress"}):
|
||
api_request("DELETE",
|
||
f"{base}/issues/{issue_number}/labels/{label_id}", auth)
|
||
return {"success": True, "message": f"Issue #{issue_number} released."}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_post_heartbeat(
|
||
issue_number: int,
|
||
branch: str,
|
||
phase: str,
|
||
pr: str = "none",
|
||
next_action: str = "none",
|
||
blocker: str = "none",
|
||
profile: str | None = None,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Post a structured progress heartbeat on a claimed issue (#268)."""
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("post_heartbeat"))
|
||
if blocked:
|
||
return blocked
|
||
verify_preflight_purity(remote, task="post_heartbeat")
|
||
active_profile = profile or get_profile().get("profile_name")
|
||
body = issue_claim_heartbeat.format_heartbeat_body(
|
||
kind="progress",
|
||
issue_number=issue_number,
|
||
branch=branch,
|
||
phase=phase,
|
||
profile=active_profile,
|
||
pr=pr,
|
||
next_action=next_action,
|
||
blocker=blocker,
|
||
)
|
||
return _post_structured_issue_comment(
|
||
issue_number=issue_number,
|
||
body=body,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
audit_op="progress_heartbeat",
|
||
)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_acquire_conflict_fix_lease(
|
||
pr_number: int,
|
||
branch: str,
|
||
worktree_path: str,
|
||
head_before: str,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Acquire a conflict-fix lease on a PR branch before pushing (#399)."""
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("comment_issue"))
|
||
if blocked:
|
||
return blocked
|
||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||
comments = _list_pr_lease_comments(
|
||
pr_number,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
reviewer_lease = pr_work_lease.find_active_reviewer_lease(
|
||
comments, pr_number=pr_number)
|
||
if reviewer_lease:
|
||
return {
|
||
"acquired": False,
|
||
"reasons": [
|
||
f"active reviewer lease on PR #{pr_number}; cannot acquire "
|
||
"conflict-fix lease (fail closed)"
|
||
],
|
||
"active_reviewer_lease": reviewer_lease,
|
||
}
|
||
profile_name = get_profile().get("profile_name") or "unknown"
|
||
body = pr_work_lease.format_conflict_fix_lease_body(
|
||
pr_number=pr_number,
|
||
branch=branch,
|
||
worktree=worktree_path,
|
||
profile=profile_name,
|
||
head_before=head_before,
|
||
reviewer_active=bool(reviewer_lease),
|
||
)
|
||
posted = _post_structured_issue_comment(
|
||
issue_number=pr_number,
|
||
body=body,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
audit_op="conflict_fix_lease_acquire",
|
||
)
|
||
return {
|
||
"acquired": posted.get("success", False),
|
||
"pr_number": pr_number,
|
||
"branch": branch,
|
||
"worktree_path": worktree_path,
|
||
"head_before": head_before,
|
||
"comment_id": posted.get("comment_id"),
|
||
"active_reviewer_lease": reviewer_lease,
|
||
"reasons": [] if posted.get("success") else ["lease comment post failed"],
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_assess_conflict_fix_push(
|
||
pr_number: int,
|
||
branch_head_before: str,
|
||
branch_head_after: str,
|
||
worktree_path: str,
|
||
push_cwd: str,
|
||
is_fast_forward: bool = True,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only pre-push gate for author conflict-fix sessions (#399)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"push_allowed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
fetched = _fetch_pr_lease_comments_safe(
|
||
pr_number,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
require_open=True,
|
||
)
|
||
if not fetched["success"]:
|
||
return {
|
||
"push_allowed": False,
|
||
"block": True,
|
||
"reasons": fetched["reasons"],
|
||
"pr_lookup": fetched.get("pr_lookup"),
|
||
"resolved_repo": fetched.get("resolved_repo"),
|
||
"remote": fetched.get("remote"),
|
||
"pr_number": pr_number,
|
||
"assessment_failed": True,
|
||
}
|
||
assessment = pr_work_lease.assess_conflict_fix_push(
|
||
pr_number=pr_number,
|
||
comments=fetched["comments"],
|
||
branch_head_before=branch_head_before,
|
||
branch_head_after=branch_head_after,
|
||
worktree_path=worktree_path,
|
||
push_cwd=push_cwd,
|
||
is_fast_forward=is_fast_forward,
|
||
)
|
||
assessment["pr_lookup"] = fetched.get("pr_lookup")
|
||
assessment["resolved_repo"] = fetched.get("resolved_repo")
|
||
assessment["remote"] = fetched.get("remote")
|
||
assessment["live_pr_head_sha"] = fetched.get("head_sha")
|
||
return assessment
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_reconcile_issue_claims(
|
||
state: str = "open",
|
||
stale_after_hours: int = 24,
|
||
heartbeat_lease_minutes: int = 30,
|
||
limit: int = 100,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Read-only inventory of issue claims and heartbeat lease status (#268)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = repo_api_url(h, o, r)
|
||
issues = api_get_all(f"{base}/issues?state={state}&type=issues", auth, limit=limit)
|
||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||
branches = api_get_all(f"{base}/branches", auth, limit=limit)
|
||
branch_names = [b.get("name") for b in branches if b.get("name")]
|
||
|
||
comments_by_issue: dict[int, list[dict]] = {}
|
||
for issue in issues:
|
||
if not issue_claim_heartbeat.issue_has_in_progress_label(issue):
|
||
continue
|
||
number = int(issue["number"])
|
||
api = f"{base}/issues/{number}/comments"
|
||
comments_by_issue[number] = api_request("GET", api, auth) or []
|
||
|
||
reclaim_after_minutes = max(heartbeat_lease_minutes * 2, 60)
|
||
inventory = issue_claim_heartbeat.build_claim_inventory(
|
||
issues=issues,
|
||
comments_by_issue=comments_by_issue,
|
||
open_prs=open_prs,
|
||
branch_names=branch_names,
|
||
heartbeat_lease_minutes=heartbeat_lease_minutes,
|
||
reclaim_after_minutes=reclaim_after_minutes,
|
||
)
|
||
live_locks = issue_lock_store.list_live_locks()
|
||
inventory["live_issue_locks"] = live_locks
|
||
inventory["live_issue_lock_numbers"] = sorted(
|
||
{
|
||
int(entry["issue_number"])
|
||
for entry in live_locks
|
||
if entry.get("issue_number") is not None
|
||
}
|
||
)
|
||
inventory["cleanup_plan"] = issue_claim_heartbeat.build_cleanup_plan(inventory)
|
||
inventory["success"] = True
|
||
inventory["performed"] = False
|
||
return inventory
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_cleanup_stale_claims(
|
||
dry_run: bool = True,
|
||
execute_confirmed: bool = False,
|
||
heartbeat_lease_minutes: int = 30,
|
||
limit: int = 100,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Propose or execute stale-claim cleanup for phantom/reclaimable issues (#268)."""
|
||
read_block = _profile_operation_gate("gitea.read")
|
||
if read_block:
|
||
return {
|
||
"success": False,
|
||
"performed": False,
|
||
"reasons": read_block,
|
||
"permission_report": _permission_block_report("gitea.read"),
|
||
}
|
||
|
||
inventory = gitea_reconcile_issue_claims(
|
||
heartbeat_lease_minutes=heartbeat_lease_minutes,
|
||
limit=limit,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
)
|
||
if not inventory.get("success"):
|
||
return inventory
|
||
|
||
plan = [
|
||
entry
|
||
for entry in inventory.get("cleanup_plan") or []
|
||
if entry.get("action") == "remove_status_in_progress_and_comment"
|
||
]
|
||
report = {
|
||
"success": True,
|
||
"dry_run": dry_run,
|
||
"performed": False,
|
||
"planned_actions": plan,
|
||
"inventory_counts": inventory.get("counts"),
|
||
}
|
||
if dry_run:
|
||
return report
|
||
|
||
if not execute_confirmed:
|
||
raise ValueError(
|
||
"execute_confirmed must be True when dry_run=False (fail closed)"
|
||
)
|
||
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("cleanup_stale_claims"))
|
||
if blocked:
|
||
return blocked
|
||
verify_preflight_purity(remote, task="cleanup_stale_claims")
|
||
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = repo_api_url(h, o, r)
|
||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||
label_id = next(
|
||
(lb["id"] for lb in labels if lb.get("name") == "status:in-progress"),
|
||
None,
|
||
)
|
||
if label_id is None:
|
||
raise RuntimeError("Label 'status:in-progress' not found")
|
||
|
||
actions: list[dict] = []
|
||
active_profile = get_profile().get("profile_name")
|
||
for entry in plan:
|
||
issue_number = int(entry["issue_number"])
|
||
with _audited(
|
||
"unlabel_issue",
|
||
host=h,
|
||
remote=remote,
|
||
org=o,
|
||
repo=r,
|
||
issue_number=issue_number,
|
||
request_metadata={"op": "cleanup_stale_claim", "label": "status:in-progress"},
|
||
):
|
||
api_request(
|
||
"DELETE",
|
||
f"{base}/issues/{issue_number}/labels/{label_id}",
|
||
auth,
|
||
)
|
||
cleanup_body = issue_claim_heartbeat.format_heartbeat_body(
|
||
kind="cleanup",
|
||
issue_number=issue_number,
|
||
branch="none",
|
||
phase="stale-claim-cleanup",
|
||
profile=active_profile,
|
||
next_action="issue reclaimable by queue",
|
||
blocker=entry.get("status") or "stale",
|
||
)
|
||
comment = _post_structured_issue_comment(
|
||
issue_number=issue_number,
|
||
body=cleanup_body,
|
||
remote=remote,
|
||
host=host,
|
||
org=org,
|
||
repo=repo,
|
||
audit_op="cleanup_heartbeat",
|
||
)
|
||
actions.append(
|
||
{
|
||
"issue_number": issue_number,
|
||
"label_removed": True,
|
||
"cleanup_comment_id": comment.get("comment_id"),
|
||
}
|
||
)
|
||
|
||
report["performed"] = True
|
||
report["actions"] = actions
|
||
return report
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_list_labels(
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> list:
|
||
"""List all available labels in a Gitea repository.
|
||
|
||
Args:
|
||
remote: Known Gitea instance ('dadeschools' or 'prgs').
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
list of labels.
|
||
"""
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = repo_api_url(h, o, r)
|
||
return api_get_all(f"{base}/labels", auth)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_create_label(
|
||
name: str,
|
||
color: str,
|
||
description: str = "",
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> dict:
|
||
"""Create a new label on a Gitea repository.
|
||
|
||
Args:
|
||
name: Name of the label (e.g. 'bug', 'epic').
|
||
color: HTML color code (hex, e.g. 'fbca04' or '#fbca04').
|
||
description: Description of the label.
|
||
remote: Known Gitea instance ('dadeschools' or 'prgs').
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
dict containing the created label details.
|
||
"""
|
||
verify_preflight_purity(remote)
|
||
h, o, r = _resolve(remote, host, org, repo)
|
||
auth = _auth(h)
|
||
base = repo_api_url(h, o, r)
|
||
|
||
if color.startswith("#"):
|
||
color = color[1:]
|
||
|
||
payload = {
|
||
"name": name,
|
||
"color": color,
|
||
"description": description,
|
||
}
|
||
with _audited("create_label", host=h, remote=remote, org=o, repo=r,
|
||
request_metadata={"name": name}):
|
||
return api_request("POST", f"{base}/labels", auth, payload)
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_set_issue_labels(
|
||
issue_number: int,
|
||
labels: list[str],
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
) -> list:
|
||
"""Replace all labels on a Gitea issue with a new list of label names.
|
||
|
||
Args:
|
||
issue_number: The issue number.
|
||
labels: List of label names to apply.
|
||
remote: Known Gitea instance ('dadeschools' or 'prgs').
|
||
host: Override the Gitea host.
|
||
org: Override the owner/organization.
|
||
repo: Override the repository name.
|
||
|
||
Returns:
|
||
list of all labels currently applied to the issue.
|
||
"""
|
||
blocked = _profile_permission_block(
|
||
task_capability_map.required_permission("set_issue_labels"))
|
||
if blocked:
|
||
return blocked
|
||
verify_preflight_purity(remote, task="set_issue_labels")
|
||
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,
|
||
)
|
||
|
||
|
||
def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]:
|
||
"""Check running runtimes and return errors if they are missing or stale."""
|
||
import subprocess
|
||
import re
|
||
from datetime import datetime
|
||
|
||
reasons = []
|
||
code_path = os.path.join(PROJECT_ROOT, "gitea_mcp_server.py")
|
||
if not os.path.exists(code_path):
|
||
return reasons
|
||
|
||
code_mtime = datetime.fromtimestamp(os.path.getmtime(code_path))
|
||
|
||
try:
|
||
proc = subprocess.run(
|
||
["ps", "-o", "pid,lstart,command", "-ax"],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
except Exception as exc:
|
||
return [f"stale-runtime: failed to list running processes: {exc}"]
|
||
|
||
self_pid = os.getpid()
|
||
self_stale = False
|
||
|
||
running_profiles = {}
|
||
for line in proc.stdout.splitlines()[1:]:
|
||
line = line.strip()
|
||
if not line or "mcp_server.py" not in line:
|
||
continue
|
||
|
||
parts = line.split(None, 6)
|
||
if len(parts) < 7:
|
||
continue
|
||
pid_str = parts[0]
|
||
lstart_str = " ".join(parts[1:6])
|
||
|
||
try:
|
||
pid = int(pid_str)
|
||
start_time = datetime.strptime(lstart_str, "%a %b %d %H:%M:%S %Y")
|
||
except Exception:
|
||
continue
|
||
|
||
try:
|
||
env_proc = subprocess.run(
|
||
["ps", "eww", str(pid)],
|
||
capture_output=True, text=True, check=True
|
||
)
|
||
env_out = env_proc.stdout
|
||
except Exception:
|
||
continue
|
||
|
||
profile = "gitea-default"
|
||
match = re.search(r'\bGITEA_MCP_PROFILE=([^\s]+)', env_out)
|
||
if match:
|
||
profile = match.group(1)
|
||
|
||
is_stale = start_time < code_mtime
|
||
if pid == self_pid and is_stale:
|
||
self_stale = True
|
||
|
||
if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]:
|
||
running_profiles[profile] = {
|
||
"pid": pid,
|
||
"start_time": start_time,
|
||
"is_stale": is_stale
|
||
}
|
||
|
||
if self_stale:
|
||
reasons.append(
|
||
"stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). "
|
||
"Please fully restart the Gitea MCP server (e.g. touch /Users/jasonwalker/.gemini/config/mcp_config.json) and retry."
|
||
)
|
||
|
||
if matching_profiles:
|
||
any_running = False
|
||
any_fresh = False
|
||
for mp in matching_profiles:
|
||
if mp in running_profiles:
|
||
any_running = True
|
||
if not running_profiles[mp]["is_stale"]:
|
||
any_fresh = True
|
||
break
|
||
if not any_running:
|
||
reasons.append(
|
||
f"stale-runtime: None of the matching profiles for task '{task}' ({matching_profiles}) are running in the OS. "
|
||
"Please restart the MCP server to ensure they are spawned."
|
||
)
|
||
elif not any_fresh:
|
||
reasons.append(
|
||
f"stale-runtime: All matching profiles for task '{task}' ({matching_profiles}) are running but stale. "
|
||
"Please fully restart the Gitea MCP server and retry."
|
||
)
|
||
|
||
return reasons
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_resolve_task_capability(
|
||
task: str,
|
||
remote: str = "dadeschools",
|
||
host: str | None = None,
|
||
) -> dict:
|
||
"""Read-only: Resolve which capability, profile, and namespace is required for a Gitea task.
|
||
|
||
Helps the client or LLM determine the correct namespace or profile before acting,
|
||
and returns exact next action instructions if the current session is not authorized.
|
||
|
||
Args:
|
||
task: The task/action to check (e.g. review_pr, create_issue).
|
||
remote: Known remote instance name.
|
||
host: Optional override for the Gitea host.
|
||
"""
|
||
TASK_MAP = task_capability_map.TASK_CAPABILITY_MAP
|
||
|
||
if task not in TASK_MAP:
|
||
raise ValueError(f"Unknown task/action: '{task}' (fail closed)")
|
||
|
||
required_permission = task_capability_map.required_permission(task)
|
||
required_role = task_capability_map.required_role(task)
|
||
|
||
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
|
||
if required_role == "reviewer":
|
||
if not infra_assessment["infra_stop"]:
|
||
capability_stop_terminal.clear()
|
||
last_route = role_session_router.last_route()
|
||
if last_route and last_route.get("route_result") == role_session_router.ROUTE_INFRA_STOP:
|
||
role_session_router.clear_route_state()
|
||
elif infra_assessment["infra_stop"]:
|
||
profile = get_profile()
|
||
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
||
username = _authenticated_username(h) if h else None
|
||
detail = "; ".join(infra_assessment.get("infra_stop_reasons") or [])
|
||
next_safe_action = (
|
||
"infra_stop: Unresolved merge conflict or mid-merge state detected in "
|
||
f"MCP runtime source ({detail}). Please resolve all conflicts manually, "
|
||
"finish/abort the merge, and retry."
|
||
)
|
||
return {
|
||
"requested_task": task,
|
||
"required_operation_permission": required_permission,
|
||
"required_role_kind": required_role,
|
||
"active_profile": profile["profile_name"],
|
||
"active_identity": username,
|
||
"active_profile_allowed_operations": profile.get("allowed_operations") or [],
|
||
"allowed_in_current_session": False,
|
||
"stop_required": True,
|
||
"infra_stop": True,
|
||
"infra_stop_assessment": infra_assessment,
|
||
"task_role_guidance": [next_safe_action],
|
||
"matching_configured_profile": [],
|
||
"runtime_switching_supported": gitea_config.is_runtime_switching_enabled(),
|
||
"different_mcp_namespace_required": False,
|
||
"exact_safe_next_action": next_safe_action,
|
||
}
|
||
|
||
record_preflight_check("capability", required_role, resolved_task=task)
|
||
|
||
# Try automatic dispatch switching
|
||
_ensure_matching_profile(required_permission, required_role, remote, host)
|
||
|
||
profile = get_profile()
|
||
config = gitea_config.load_config()
|
||
|
||
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
|
||
username = _authenticated_username(h) if h else None
|
||
|
||
# Load active permissions
|
||
active_allowed = profile.get("allowed_operations") or []
|
||
active_forbidden = profile.get("forbidden_operations") or []
|
||
|
||
# Check if allowed in current session
|
||
allowed_in_current_session, _ = gitea_config.check_operation(
|
||
required_permission, active_allowed, active_forbidden
|
||
)
|
||
|
||
switching = gitea_config.is_runtime_switching_enabled()
|
||
available_in_session = allowed_in_current_session
|
||
configured = False
|
||
restart_required = False
|
||
reason_msg = None
|
||
|
||
# Find matching configured profiles
|
||
matching_profiles = []
|
||
if config and "profiles" in config:
|
||
for p_name, p_data in config["profiles"].items():
|
||
p_allowed = p_data.get("allowed_operations") or []
|
||
p_forbidden = p_data.get("forbidden_operations") or []
|
||
p_allowed_n = []
|
||
for op in p_allowed:
|
||
try:
|
||
p_allowed_n.append(gitea_config.normalize_operation(op))
|
||
except Exception:
|
||
pass
|
||
p_forbidden_n = []
|
||
for op in p_forbidden:
|
||
try:
|
||
p_forbidden_n.append(gitea_config.normalize_operation(op))
|
||
except Exception:
|
||
pass
|
||
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
|
||
if ok:
|
||
matching_profiles.append(p_name)
|
||
|
||
configured = len(matching_profiles) > 0
|
||
available_in_session = allowed_in_current_session
|
||
|
||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||
runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles)
|
||
if runtime_reasons:
|
||
restart_required = True
|
||
reason_msg = "; ".join(runtime_reasons)
|
||
next_safe_action = (
|
||
"stale-runtime: Gitea MCP runtime conflict or missing process detected. "
|
||
"Please fully restart the Gitea MCP server and retry."
|
||
)
|
||
|
||
if not allowed_in_current_session:
|
||
if configured and switching:
|
||
restart_required = True
|
||
available_in_session = False
|
||
reason_msg = (
|
||
f"{required_role.capitalize()} profile exists but MCP server "
|
||
"was added after session startup and is not attached."
|
||
)
|
||
elif not configured:
|
||
reason_msg = (
|
||
f"No profile configured with permission '{required_permission}'."
|
||
)
|
||
different_namespace_required = False
|
||
next_safe_action = "None; ready for operations."
|
||
|
||
if not allowed_in_current_session:
|
||
if switching:
|
||
different_namespace_required = False
|
||
next_safe_action = f"Switch to a profile that has the required permission by calling gitea_activate_profile (matching configured profiles: {matching_profiles})."
|
||
else:
|
||
different_namespace_required = True
|
||
if required_role == "reviewer":
|
||
next_safe_action = (
|
||
"Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, "
|
||
"or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
||
)
|
||
elif required_role == "author":
|
||
next_safe_action = (
|
||
"Switch to the author MCP session (e.g. gitea-author) which has author permissions configured, "
|
||
"or ask the operator to update GITEA_MCP_PROFILE to an author profile."
|
||
)
|
||
else:
|
||
next_safe_action = (
|
||
f"Relaunch the server with GITEA_MCP_PROFILE set to a profile that has the required permission (e.g. one of: {matching_profiles}) "
|
||
"or use the corresponding MCP namespace."
|
||
)
|
||
|
||
# Task/role alignment guards (#167): the requested task, not the
|
||
# available credential, decides what the session may do. A review/merge
|
||
# task under a non-reviewer profile must stop — not silently degrade
|
||
# into author-side mutations.
|
||
stop_required = not allowed_in_current_session
|
||
task_role_guidance = []
|
||
if required_role == "reviewer":
|
||
if allowed_in_current_session:
|
||
task_role_guidance.append(
|
||
"Review/merge task: proceed only through the gated "
|
||
"review/merge tools and their eligibility checks; "
|
||
"self-review and self-merge remain blocked.")
|
||
else:
|
||
task_role_guidance.append(
|
||
"STOP: the requested task is review/merge but the active "
|
||
"profile cannot perform it. Do not fall back to author-side "
|
||
"work — do not commit, push, edit files, or comment as "
|
||
"author unless the operator explicitly changes the task to "
|
||
"author-side remediation (task "
|
||
"'address_pr_change_requests').")
|
||
elif task == "address_pr_change_requests":
|
||
if allowed_in_current_session:
|
||
task_role_guidance.append(
|
||
"Author-side remediation: you may commit and push fixes to "
|
||
"the PR branch, but you must not submit review verdicts and "
|
||
"must not merge.")
|
||
else:
|
||
task_role_guidance.append(
|
||
"STOP: author-side remediation requires an author profile "
|
||
"with branch push permission; the active profile cannot "
|
||
"perform it.")
|
||
elif not allowed_in_current_session:
|
||
task_role_guidance.append(
|
||
"STOP: the active profile cannot perform the requested task; "
|
||
"follow exact_safe_next_action instead of improvising.")
|
||
|
||
if task == "review_pr":
|
||
init_review_decision_lock(
|
||
remote if remote in REMOTES else None,
|
||
task,
|
||
force=False,
|
||
)
|
||
|
||
record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task)
|
||
|
||
result = {
|
||
"requested_task": task,
|
||
"required_operation_permission": required_permission,
|
||
"required_role_kind": required_role,
|
||
"active_profile": profile["profile_name"],
|
||
"active_identity": username,
|
||
"active_profile_allowed_operations": active_allowed,
|
||
"allowed_in_current_session": allowed_in_current_session,
|
||
"available_in_session": available_in_session,
|
||
"configured": configured,
|
||
"restart_required": restart_required,
|
||
"stop_required": stop_required or restart_required,
|
||
"task_role_guidance": task_role_guidance,
|
||
"matching_configured_profile": matching_profiles,
|
||
"runtime_switching_supported": switching,
|
||
"different_mcp_namespace_required": different_namespace_required,
|
||
"exact_safe_next_action": next_safe_action,
|
||
}
|
||
if reason_msg:
|
||
result["reason"] = reason_msg
|
||
if task in ("review_pr", "merge_pr"):
|
||
result["workflow_load_proof"] = review_workflow_load.workflow_load_status(
|
||
PROJECT_ROOT)
|
||
if not result["workflow_load_proof"].get("workflow_load_valid"):
|
||
guidance = (
|
||
"Call gitea_load_review_workflow before any reviewer review "
|
||
"or merge mutation."
|
||
)
|
||
if guidance not in task_role_guidance:
|
||
task_role_guidance.append(guidance)
|
||
role_session_router.sync_route_from_capability(result)
|
||
if audit_reconciliation_mode.check_audit_task_enters_phase(task):
|
||
phase_record = audit_reconciliation_mode.enter_audit_phase(task)
|
||
result["reconciliation_phase"] = phase_record.get("phase")
|
||
was_terminal = capability_stop_terminal.is_active()
|
||
terminal = capability_stop_terminal.sync_from_capability_result(result)
|
||
if terminal:
|
||
result["terminal_mode"] = True
|
||
result["terminal_report"] = (
|
||
capability_stop_terminal.build_terminal_report(result)
|
||
)
|
||
elif was_terminal and not (stop_required or restart_required):
|
||
result["cleared_stale_denial"] = True
|
||
return result
|
||
|
||
|
||
@mcp.tool()
|
||
def gitea_capability_stop_terminal_report() -> dict:
|
||
"""Read-only: terminal report template after reviewer capability denial (#197)."""
|
||
record = capability_stop_terminal.active_record()
|
||
if not record:
|
||
return {
|
||
"terminal_mode": False,
|
||
"reasons": ["capability stop terminal mode is not active"],
|
||
}
|
||
return capability_stop_terminal.build_terminal_report({
|
||
"requested_task": record.get("requested_task"),
|
||
"required_role_kind": record.get("required_role_kind"),
|
||
"active_profile": record.get("active_profile"),
|
||
"active_identity": record.get("active_identity"),
|
||
"stop_required": record.get("stop_required"),
|
||
"exact_safe_next_action": record.get("exact_safe_next_action"),
|
||
})
|
||
|
||
|
||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
# Lock this session's launch profile into the environment so child CLI
|
||
# processes (e.g. review_pr.py) can detect and refuse profile
|
||
# side-channel overrides (#199).
|
||
_export_session_profile_lock()
|
||
mcp.run(transport="stdio")
|