0bc734c020
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>
687 lines
25 KiB
Python
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())
|