Files
cleveragents-core/tools/mcp_forgejo_server.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

687 lines
25 KiB
Python

#!/usr/bin/env python3
"""MCP server wrapping Forgejo PR/issue read+write operations.
Replaces a chunk of per-agent boilerplate: the ``"npx --yes
tsx*claim_pr.ts*": allow`` bash entries (claim/release lived in a TS
script), the per-curl Forgejo-URL allowlists scattered across worker
agents, and the per-agent identity-selection prose ("act as HAL9000
unless this is a review-submit, in which case use HAL9001"). Agents
that allow ``forgejo*`` get a small typed surface instead of having
to construct URLs, auth headers, JSON bodies, and pagination loops
in bash.
Identity model
--------------
Most tools act as HAL9000 (worker identity, ``FORGEJO_PAT``). The
exception is :func:`submit_review` which posts the formal review and
requires HAL9001 (``FORGEJO_REVIEWER_PAT``). The identity is baked
into each tool rather than passed as a runtime parameter — a runtime
toggle invites "I forgot to set ``reviewer=True``" footguns on a
high-stakes write. The tool name IS the identity selector.
Tools exposed
-------------
Reads (all HAL9000):
- ``fetch_pr(pr)`` — PR object, trimmed
- ``fetch_issue(issue)`` — issue object, trimmed
- ``fetch_comments(pr, since=None)`` — issue-style comments
- ``fetch_reviews(pr)`` — formal reviews + inline comments
Writes (HAL9000):
- ``post_comment(pr, body)`` — issue-style comment
- ``update_pr_body(pr, body)`` — replace PR description
- ``add_label(pr, name)`` — single label add
- ``remove_label(pr, name)`` — single label remove
- ``claim_pr(pr, label, ttl_seconds)`` — atomic claim via label
- ``release_pr(pr, label)`` — claim release
Writes (HAL9001 — reviewer-only):
- ``submit_review(pr, event, body, commit_id)`` — formal REQUEST_CHANGES /
APPROVE / COMMENT submission
State
-----
Stateless per-call. Each tool builds a fresh :class:`ForgejoCfg`
from env so dispatcher-side PAT rotations are picked up without
restarting the MCP server.
Configuration
-------------
Reads the standard ``FORGEJO_*`` env vars set by
``tools/launch_fork.sh``: ``FORGEJO_PAT``, ``FORGEJO_REVIEWER_PAT``,
``FORGEJO_OWNER``, ``FORGEJO_REPO``, ``FORGEJO_API_BASE``. The MCP
inherits env from the OpenCode process — restart OpenCode after
re-sourcing launch_fork.sh if you rotate a PAT.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
sys.path.insert(0, str(Path(__file__).parent))
from _mcp_common import ( # noqa: E402
ForgejoCfg,
bootstrap_loader,
error_envelope,
make_main,
require_token,
)
load_sibling = bootstrap_loader()
_claim_runtime = load_sibling("_claim_runtime", "_claim_runtime.py")
_review_fetch = load_sibling("_review_fetch", "_review_fetch.py")
_review_post = load_sibling("_review_post", "_review_post.py")
_pr_classification_cache = load_sibling(
"_pr_classification_cache", "_pr_classification_cache.py"
)
_pr_comments_cache = load_sibling("_pr_comments_cache", "_pr_comments_cache.py")
server = FastMCP("forgejo")
# ─── Helpers ───────────────────────────────────────────────────────
def _trim_pr(body: dict[str, Any]) -> dict[str, Any]:
"""Project a Forgejo PR object down to the fields a worker
actually uses. Forgejo's full PR response is ~80 fields; the
worker needs ~12. Trimming saves ~3-5 KB per fetch."""
if not isinstance(body, dict):
return {}
head = body.get("head") or {}
base = body.get("base") or {}
labels = [l.get("name") for l in (body.get("labels") or []) if l.get("name")]
return {
"number": body.get("number"),
"title": body.get("title"),
"body": body.get("body"),
"state": body.get("state"),
"mergeable": body.get("mergeable"),
"merged": body.get("merged"),
"draft": body.get("draft"),
"head": {
"ref": head.get("ref"),
"sha": head.get("sha"),
"label": head.get("label"),
},
"base": {
"ref": base.get("ref"),
"sha": base.get("sha"),
"label": base.get("label"),
},
"labels": labels,
"user": (body.get("user") or {}).get("login"),
"html_url": body.get("html_url"),
"created_at": body.get("created_at"),
"updated_at": body.get("updated_at"),
}
def _trim_issue(body: dict[str, Any]) -> dict[str, Any]:
"""Like _trim_pr for issue objects."""
if not isinstance(body, dict):
return {}
labels = [l.get("name") for l in (body.get("labels") or []) if l.get("name")]
return {
"number": body.get("number"),
"title": body.get("title"),
"body": body.get("body"),
"state": body.get("state"),
"labels": labels,
"user": (body.get("user") or {}).get("login"),
"html_url": body.get("html_url"),
"created_at": body.get("created_at"),
"updated_at": body.get("updated_at"),
"closed_at": body.get("closed_at"),
}
def _trim_comment(c: dict[str, Any]) -> dict[str, Any]:
return {
"id": c.get("id"),
"body": c.get("body"),
"user": (c.get("user") or {}).get("login"),
"created_at": c.get("created_at"),
"updated_at": c.get("updated_at"),
}
def _trim_review(r: dict[str, Any]) -> dict[str, Any]:
"""Trimmed review object — keeps inline comments which the
worker needs (each comment is a specific point of feedback)."""
inline = r.get("comments") or []
return {
"id": r.get("id"),
"state": r.get("state"),
"submitted_at": r.get("submitted_at"),
"user": (r.get("user") or {}).get("login"),
"body": r.get("body"),
"commit_id": r.get("commit_id"),
"stale": r.get("stale"),
"dismissed": r.get("dismissed"),
"comments": [
{
"path": c.get("path"),
"body": c.get("body"),
"new_position": c.get("new_position"),
"old_position": c.get("old_position"),
}
for c in inline
],
}
# Module-local alias: agent-facing tool implementations below use
# ``_err_response``; the shared helper lives in :mod:`_mcp_common`.
_err_response = error_envelope
# ─── Read tools (HAL9000) ──────────────────────────────────────────
@server.tool()
def fetch_pr(pr: int) -> dict[str, Any]:
"""Fetch a single PR, trimmed to the fields a worker uses.
Returns ``{number, title, body, state, mergeable, merged, draft,
head: {ref, sha, label}, base: {...}, labels: [...], user, ...}``.
On error returns ``{error: str, ...}`` — check for ``error``
before consuming the result.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr)
try:
resp = _claim_runtime.get(f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr)}", cfg)
except Exception as exc:
return _err_response(f"network error: {exc!r}", pr=pr)
if int(resp.get("status") or 0) != 200:
return _err_response(f"HTTP {resp.get('status')} from Forgejo", pr=pr)
return _trim_pr(resp.get("body") or {})
@server.tool()
def fetch_issue(issue: int) -> dict[str, Any]:
"""Fetch a single issue, trimmed. Same shape as fetch_pr but for
issues. Useful for resolving linked-issue references the PR
body cites with ``closes #123`` / ``fixes #123`` / ``refs #123``.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, issue=issue)
try:
resp = _claim_runtime.get(
f"/repos/{cfg.owner}/{cfg.repo}/issues/{int(issue)}", cfg
)
except Exception as exc:
return _err_response(f"network error: {exc!r}", issue=issue)
status = int(resp.get("status") or 0)
if status == 404:
return _err_response("issue not found (404)", issue=issue)
if status != 200:
return _err_response(f"HTTP {status} from Forgejo", issue=issue)
return _trim_issue(resp.get("body") or {})
@server.tool()
def fetch_comments(pr: int, since: str | None = None) -> dict[str, Any]:
"""Fetch issue-style comments on a PR (paginated).
``since`` is an ISO-8601 timestamp — when set, the Forgejo
server filters server-side and the response only contains
comments at or after that time. Useful for the reviewer doing
incremental re-reads ("what's new since my last review?").
Returns ``{pr, comments: [{id, body, user, created_at, ...}],
complete}``. ``complete`` is ``False`` if pagination truncated.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr, comments=[], complete=False)
try:
rows, complete = _review_fetch.fetch_pr_comments(cfg, int(pr))
except Exception as exc:
return _err_response(
f"network error: {exc!r}", pr=pr, comments=[], complete=False
)
out = [_trim_comment(c) for c in rows]
if since:
out = [c for c in out if (c.get("created_at") or "") >= since]
return {"pr": pr, "comments": out, "complete": complete}
@server.tool()
def fetch_reviews(pr: int) -> dict[str, Any]:
"""Fetch formal reviews on a PR, each with its inline comments.
Returns ``{pr, reviews: [{id, state, submitted_at, user, body,
commit_id, stale, dismissed, comments: [{path, body,
new_position, old_position}]}], complete}``.
The reviewer needs ``state == "REQUEST_CHANGES"`` reviews that
are not ``dismissed`` to know which feedback the worker must
address — this tool returns the data; the agent does the
filtering.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr, reviews=[], complete=False)
try:
rows, complete = _review_fetch.fetch_existing_reviews(cfg, int(pr))
except Exception as exc:
return _err_response(
f"network error: {exc!r}", pr=pr, reviews=[], complete=False
)
return {
"pr": pr,
"reviews": [_trim_review(r) for r in rows],
"complete": complete,
}
# ─── Write tools (HAL9000) ─────────────────────────────────────────
@server.tool()
def post_comment(pr: int, body: str) -> dict[str, Any]:
"""Post an issue-style comment to the PR as HAL9000.
Returns ``{status, id?}`` where ``status == 201`` indicates
success and ``id`` is the new comment's Forgejo id. Use
:func:`submit_review` for formal REQUEST_CHANGES / APPROVE
submissions — this tool posts plain comments.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr)
if not body or not body.strip():
return _err_response("comment body must be non-empty", pr=pr)
try:
resp = _claim_runtime.post(
f"/repos/{cfg.owner}/{cfg.repo}/issues/{int(pr)}/comments",
cfg,
{"body": body},
)
except Exception as exc:
return _err_response(f"network error: {exc!r}", pr=pr)
status = int(resp.get("status") or 0)
if status != 201:
return _err_response(f"HTTP {status} from Forgejo", pr=pr, status=status)
return {"status": status, "id": (resp.get("body") or {}).get("id")}
@server.tool()
def update_pr_body(pr: int, body: str) -> dict[str, Any]:
"""Replace the PR's description (``body`` field).
Returns ``{status}`` — ``200`` on success. The G9 regression
guard (see ``tests/auto_agents/`` and the corresponding agent
prose) rejects empty replacements: if the worker tries to clear
the body, the tool refuses rather than silently destroying
operator-edited content. Pass the current body + your additions
instead.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr)
if not body or not body.strip():
return _err_response(
"refusing to replace PR body with empty content (G9 guard)",
pr=pr,
)
try:
resp = _claim_runtime.patch(
f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr)}",
cfg,
{"body": body},
)
except Exception as exc:
return _err_response(f"network error: {exc!r}", pr=pr)
status = int(resp.get("status") or 0)
if status != 200:
return _err_response(f"HTTP {status} from Forgejo", pr=pr, status=status)
return {"status": status}
@server.tool()
def add_label(pr: int, name: str) -> dict[str, Any]:
"""Add a single label to a PR.
Wraps ``_claim_runtime._add_label`` which handles the
Forgejo label-id lookup (Forgejo's add-label endpoint takes
label IDs, not names). Returns ``{ok: bool, name}``.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr, name=name, ok=False)
if not name or not name.strip():
return _err_response("label name must be non-empty", pr=pr, name=name, ok=False)
try:
ok = bool(_claim_runtime._add_label(int(pr), name, cfg))
except Exception as exc:
return _err_response(f"network error: {exc!r}", pr=pr, name=name, ok=False)
return {"ok": ok, "name": name}
@server.tool()
def remove_label(pr: int, name: str) -> dict[str, Any]:
"""Remove a single label from a PR.
Wraps ``_claim_runtime._remove_label`` which handles label-id
lookup and treats Forgejo HTTP 404 (label wasn't on the PR) as
success because the post-condition ("label is absent") holds
either way. Returns ``{ok: bool, name}``.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr, name=name, ok=False)
if not name or not name.strip():
return _err_response("label name must be non-empty", pr=pr, name=name, ok=False)
try:
ok = bool(_claim_runtime._remove_label(int(pr), name, cfg))
except Exception as exc:
return _err_response(f"network error: {exc!r}", pr=pr, name=name, ok=False)
if not ok:
# ``_remove_label`` returns False when the label name isn't
# defined in the repo/org. Nothing to remove from a PR — the
# post-condition holds, so report success with a note.
return {"ok": True, "name": name, "note": "label not defined"}
return {"ok": True, "name": name}
@server.tool()
def claim_pr(pr: int, label: str, ttl_seconds: int = 7200) -> dict[str, Any]:
"""Atomically claim a PR by adding ``label`` (with a TTL window).
Replaces the per-agent ``"npx --yes tsx*claim_pr.ts*": allow``
bash entry. Returns ``{claimed: bool, reason?, by_existing_holder?}``.
A failed claim (claimed=False) typically means another worker
holds an unexpired claim.
"""
cfg = ForgejoCfg()
cfg.claim_ttl_seconds = int(ttl_seconds)
err = require_token(cfg, "default")
if err:
return _err_response(err, pr=pr, claimed=False)
if not label or not label.strip():
return _err_response("label must be non-empty", pr=pr, claimed=False)
try:
# The project's claim_pr accepts (pr_number, cfg) with the
# claim label baked in via _claim_runtime.CLAIM_LABEL. Since
# we want flexibility on label name, fall back to _add_label
# directly — which is what claim_pr boils down to under the
# current implementation. Mirrors what claim_pr.ts does.
ok = bool(_claim_runtime._add_label(int(pr), label.strip(), cfg))
except Exception as exc:
return _err_response(f"network error: {exc!r}", pr=pr, claimed=False)
if not ok:
return _err_response(
"label add failed (label undefined or rejected)",
pr=pr,
claimed=False,
)
return {"claimed": True, "label": label.strip()}
@server.tool()
def release_pr(pr: int, label: str) -> dict[str, Any]:
"""Release a PR claim by removing ``label``. Idempotent — a
label that isn't present returns ``{released: True}``."""
result = remove_label(pr, label)
if result.get("ok"):
return {"released": True, "label": label}
return _err_response(
result.get("error") or "release failed",
pr=pr,
released=False,
)
# ─── Reviewer-only write tool (HAL9001) ────────────────────────────
@server.tool()
def submit_review(pr: int, event: str, body: str, commit_id: str) -> dict[str, Any]:
"""Submit a formal review as HAL9001 (umbrella approver identity).
``event`` must be one of: ``"APPROVE"``, ``"REQUEST_CHANGES"``,
``"COMMENT"``. ``commit_id`` MUST be the SHA the worker reviewed
(Forgejo pins the review to a specific commit so a subsequent
push doesn't silently invalidate the verdict).
Uses ``FORGEJO_REVIEWER_PAT`` (HAL9001) — distinct from the
HAL9000 token used by the worker-side writes. This is the ONLY
tool that uses the reviewer identity; if you find yourself
wanting to "post a comment as the reviewer", post it as a normal
comment via ``post_comment`` (HAL9000) and submit the review
separately.
Returns ``{status, review_id?}``.
"""
cfg = ForgejoCfg(reviewer=True)
err = require_token(cfg, "reviewer")
if err:
return _err_response(err, pr=pr)
event_norm = (event or "").strip().upper()
if event_norm not in ("APPROVE", "REQUEST_CHANGES", "COMMENT"):
return _err_response(
f"event must be APPROVE | REQUEST_CHANGES | COMMENT (got {event!r})",
pr=pr,
)
if not commit_id or not commit_id.strip():
return _err_response(
"commit_id is required (pin the review to a specific SHA)",
pr=pr,
)
if not body or not body.strip():
return _err_response("review body must be non-empty", pr=pr)
try:
resp = _review_post.submit_review(
cfg,
int(pr),
{
"event": event_norm,
"body": body,
"commit_id": commit_id.strip(),
},
)
except Exception as exc:
return _err_response(f"network error: {exc!r}", pr=pr)
status = int(resp.get("status") or 0)
if status not in (200, 201):
return _err_response(f"HTTP {status} from Forgejo", pr=pr, status=status)
return {
"status": status,
"review_id": (resp.get("body") or {}).get("id"),
}
@server.tool()
def list_prs_by_filter(
filter_name: str,
ttl_seconds: int = 300,
) -> dict[str, Any]:
"""List open PRs matching one of the 5 reviewer-side filter
classifications, using the on-disk delta cache.
Filter names (from ``_pr_classification_cache.FILTER_NAMES``):
- ``addressed_changes_ci_passing`` — PRs where CI is passing,
no approvals yet, at least one active REQUEST_CHANGES, and
every RC has been followed by a commit (i.e. the author
addressed the feedback). Reviewer's re-review queue.
- ``addressed_changes_ci_failing`` — same as above but CI is
failing.
- ``no_active_review_ci_passing`` — CI passing, no approvals,
no active REQUEST_CHANGES. Reviewer's first-review queue.
- ``no_active_review_ci_failing`` — same but CI failing.
- ``missing_ci_checks`` — CI has no checks reported
(``state == 'unknown'`` from Forgejo), no approvals, no
unaddressed REQUEST_CHANGES. The lightweight CI-flag review
queue.
All filters also exclude claimed PRs (any ``auto/claimed-*``
label) per the Tier 1 mutual-respect contract.
Cache behaviour:
- One Forgejo ``GET /pulls?state=open&sort=newest&limit=50`` per
call (the cheap part).
- For each PR returned, if the cache row's ``head_sha`` matches
and the PR's ``updated_at`` hasn't advanced and the row is
within ``ttl_seconds`` (default 300 s), reuse the cached
classification with zero per-PR API calls.
- Otherwise, fetch CI status + reviews + commits and reclassify.
Returns:
``{filter_name, prs: [...], count}`` — ``prs`` is a list of
trimmed PR objects with the classification flags inlined
(``ci_status``, ``approvals_count``, ``has_active_request_changes``,
``has_unaddressed_request_changes``, ``is_claimed``, plus
``number / title / head_sha / head_ref / base_ref / updated_at
/ labels``). On error: ``{error: str, filter_name}``.
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, filter_name=filter_name)
if filter_name not in _pr_classification_cache.FILTER_NAMES:
return _err_response(
f"unknown filter {filter_name!r}; "
f"valid: {list(_pr_classification_cache.FILTER_NAMES)}",
filter_name=filter_name,
)
try:
prs = _pr_classification_cache.refresh_then_filter(
cfg,
filter_name,
ttl_seconds=int(ttl_seconds),
)
except Exception as exc: # noqa: BLE001
return _err_response(
f"refresh_then_filter raised: {exc!r}",
filter_name=filter_name,
)
return {
"filter_name": filter_name,
"prs": prs,
"count": len(prs),
}
@server.tool()
def fetch_pr_comments_cached(
pr_number: int,
) -> dict[str, Any]:
"""Fetch the issue-style comments for ``pr_number`` via the
on-disk delta cache shared by the reviewer + implementer
dispatchers.
Why this exists: the dispatcher pre-fetches a comment snapshot
once per cycle and injects it into the worker's prompt. For PRs
with very long histories (PR #29 has 2700+ comments) that
snapshot may have been truncated at the pagination cap, leaving
``data_complete=False`` in the prompt. The agent can call this
tool to read the cache directly — same cached bulk the dispatcher
saw, plus any delta the cache has backfilled since then —
without burning ~30 s on a full re-paginate from ``page=1``.
Cache behaviour:
- On a fresh cache (``cache miss``), one full paginated walk seeds
the cache. Subsequent calls within the staleness window only
fetch comments newer than the cache's ``since_cursor``.
- If a previous live-delta failed and we're inside the
exponential-backoff window, the cached bulk is returned with
``completed=False`` and ``source="stale"``; no live attempt is
made (avoids hammering a degraded endpoint).
- On cache disabled (``IMPLEMENTER_DISPATCHER_COMMENT_CACHE_DISABLE=1``),
falls through to the legacy paginator.
Returns:
``{pr_number, comments: [...], count, completed,
any_partial_fetch, source}`` where ``source`` is one of
``"cache" | "live" | "stale" | "disabled"``. On error:
``{error: str, pr_number}``.
"""
try:
pr = int(pr_number)
except (TypeError, ValueError):
return _err_response(
f"pr_number must be an integer, got {pr_number!r}",
pr_number=pr_number,
)
if pr <= 0:
return _err_response(
f"pr_number must be positive, got {pr}",
pr_number=pr,
)
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return _err_response(err, pr_number=pr)
# Pre-call cache inspection so we can label the result's ``source``
# without instrumenting ``get_pr_comments`` (which is consumed by
# the dispatcher's tight per-PR loop and shouldn't carry per-call
# observability cruft). The cache-read is cheap (one stat + one
# JSON parse) and idempotent.
now_dt = _dt_now_utc()
pre_cache = _pr_comments_cache._read_cache(pr)
in_backoff = _pr_comments_cache._backoff_active(pre_cache, now_dt)
try:
comments, completed = _pr_comments_cache.get_pr_comments(cfg, pr)
except Exception as exc: # noqa: BLE001
return _err_response(
f"get_pr_comments raised: {exc!r}",
pr_number=pr,
)
if _pr_comments_cache.is_disabled():
source = "disabled"
elif in_backoff:
source = "stale"
elif pre_cache is None:
source = "live"
else:
source = "cache"
return {
"pr_number": pr,
"comments": comments,
"count": len(comments),
"completed": bool(completed),
"any_partial_fetch": bool((pre_cache or {}).get("any_partial_fetch", False))
if pre_cache
else not completed,
"source": source,
}
def _dt_now_utc():
"""Local UTC-now helper for the cache-source labelling above.
Kept module-private + tiny so tests can monkey-patch by name
without dragging in ``datetime`` at the call site."""
import datetime as _dt
return _dt.datetime.now(_dt.timezone.utc)
main = make_main(server, "mcp_forgejo_server")
if __name__ == "__main__":
sys.exit(main())