2658deee94
Adds a long-lived sidecar (pr_state_warmer.py) that polls Forgejo's /pulls endpoint every 30s and writes the full PR snapshot to a shared SQLite store, eliminating the dispatcher's per-cycle cold-cache stalls (24-30s rebuilds on flaky cycles) and the silent 50-PR pagination cap on the legacy single-page fetch. Substrate - tools/_pr_state_cache.py — SQLite store with (owner, repo) PK, WAL mode, additive v2→v3 migration (comments_refreshed_updated_at), bounded fcntl.flock migration lock, threading.Lock for per-process init, @_with_reheal decorator (catches OperationalError no-such- table + DatabaseError corruption with file quarantine), atomic TEMP-table chunking for >32k seen-set, _normalize_updated_at to canonicalize Forgejo tz-marker drift - tools/pr_state_warmer.py — poll/upsert/vanish/comments-refresh loop with fcntl.flock singleton (rejects second warmer), bounded comments-refresh cap, persistent deferral via SQL pending query, PermissionError-tolerant lock setup, cold-start log suppression - tools/_pr_classification_cache.py — three-layer fall-through (warmer cache → list cache → live fetch) with staleness gate (PR_STATE_WARMER_STALE_AFTER_S floored at 30s in prod) Comments cache hardening - Bot-filter at write time drops bot status/claim/release/sentinel while preserving **Implementation Attempt** markers (94.6% reduction on bot-heavy PRs like #30's 19k-comment thread) - _normalize_since_cursor strips microsecond precision before building ?since= query (fixes the live-observed Forgejo HTTP 422 bug on PRs #25 + #28); handles uppercase Z, lowercase z, ±HH:MM offsets (including non-zero like +05:30), naive ISO - Lazy migration of legacy null-key by_author entries on _read_cache - _newest_cursor walks tail-back skipping malformed entries Supporting infrastructure (cumulative dmpipeline-v2 work) - Telemetry server: SSE live tail, run-sessions enumeration, cost/token tracking, app.js UI rewrite with collapsible sections - MCP servers (mcp_ci_server, mcp_forgejo_server, mcp_git_server, mcp_handoff_server, mcp_graphify_server) for opencode worker context access - Live log writer (tools/live_log_writer.py) — SSE-streaming dispatcher event log - Tier-dispatcher escalation flow with prompts trimmed for budget - Shared bot-logins resolver (tools/_bot_logins.py) replacing two drift-prone copies - token_usage_audit.py for opencode cost analysis Tests - 2259 passing across 65 changed/new files - New suites: test_pr_state_cache, test_pr_state_warmer, test_pr_state_warmer_integration, test_pr_classification_cache, test_pr_list_cache_backoff, test_mcp_* (5 servers), test_live_log_writer_sse, test_telemetry_run_sessions, test_review_post_ready_label - Test_pr_comments_cache expanded with bot-filter coverage, cursor-normalization regression pins, format-drift, atomicity, failed-comments-not-stamped (silent-data-loss class) - Parametrized @_with_reheal coverage across 7 wrapped APIs - Real fault-inject atomicity test for chunked mark_vanished path via Connection wrapper class - Subprocess-based singleton flock test (cross-process contract) - Event-driven SIGTERM-mid-poll test (no fixed-sleep flake) Architecture notes - Schema v3 migration is additive (ALTER ADD COLUMN); v0/v1 still need destructive rebuild because pre-v2 column shape lacks owner/repo. Cross-process drop-table-ping-pong prevented by the fcntl migration lock + per-process _initialized flag. - Comments-refresh deferral is persistent via comments_refreshed_updated_at column — survives warmer restart, picks up next cycle even if PR didn't change again. Replaces in-memory changed_numbers list. - Rollback path: PR_STATE_WARMER_PREFER=0 bypasses the warmer cache and reverts to live-fetch behavior. PR_STATE_CACHE_DISABLE=1 short-circuits the warmer process at startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
551 lines
19 KiB
Python
551 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""MCP server wrapping the project's CI surface — local quality gates
|
||
(``tools/local_ci_gate.sh``) and Forgejo CI status fetches.
|
||
|
||
The motivating problem: a single CI-failure investigation cycle today
|
||
burns 20–50 KB of worker context just READING gate / pytest / mypy /
|
||
ruff / behave output to find the one failing test. The model parses
|
||
multi-thousand-line output to extract a file:line:test_name triple.
|
||
This MCP returns that triple directly — typically a few hundred
|
||
bytes — letting the agent spend its context on the actual fix.
|
||
|
||
Tools exposed
|
||
-------------
|
||
|
||
``run_local_gate(gate, repo_root=None, fast=False, posargs=None)``
|
||
Run ``bash tools/local_ci_gate.sh`` against ``repo_root`` (defaults
|
||
to ``/tmp/local_tools/`` when called from a worker; the script's
|
||
own repo-root resolution kicks in when omitted). Returns a
|
||
structured result with per-gate PASS/FAIL/SKIP plus a parsed
|
||
``failures`` array. The raw tail is included so an agent can
|
||
fall back to direct inspection if the parser missed something.
|
||
|
||
``fetch_pr_check_summary(pr)``
|
||
Per-check status for the PR's HEAD SHA, via Forgejo's
|
||
``/commits/{sha}/statuses`` endpoint (paginated; not the combined
|
||
``/status`` summary). Returns a compact array — no log URLs
|
||
inlined unless useful — so the agent can decide which check to
|
||
drill into without first reading the full Forgejo response.
|
||
|
||
State
|
||
-----
|
||
|
||
Stateless. Every call either shells out fresh (``run_local_gate``)
|
||
or hits Forgejo (``fetch_pr_check_summary``). No caching here — the
|
||
underlying state changes too quickly for cache invalidation to be
|
||
worth the complexity.
|
||
|
||
Configuration
|
||
-------------
|
||
|
||
``CI_GATE_SCRIPT`` (default
|
||
``/home/drew/repos/cleveragents-core/tools/local_ci_gate.sh``)
|
||
Path to the gate wrapper script. Override if the project layout
|
||
moves the script or to test against a fork.
|
||
|
||
``CI_GATE_DEFAULT_REPO_ROOT`` (optional)
|
||
Default ``--repo-root`` passed to the gate wrapper. Leave unset
|
||
to let the wrapper's own resolution apply (which prefers cwd,
|
||
then walks up looking for ``noxfile.py``).
|
||
|
||
``CI_GATE_TIMEOUT_S`` (default ``1800`` — 30 min)
|
||
Per-call subprocess timeout. ``e2e_tests`` and ``coverage_report``
|
||
can take 10+ minutes on a cold cache; the default leaves headroom.
|
||
|
||
The Forgejo tools read the standard ``FORGEJO_PAT``, ``FORGEJO_OWNER``,
|
||
``FORGEJO_REPO``, ``FORGEJO_API_BASE`` env vars — same contract as
|
||
``tools/dispatch_*.py``.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from mcp.server.fastmcp import FastMCP
|
||
|
||
# ─── Configuration ─────────────────────────────────────────────────
|
||
CI_GATE_SCRIPT = Path(
|
||
os.environ.get(
|
||
"CI_GATE_SCRIPT",
|
||
"/home/drew/repos/cleveragents-core/tools/local_ci_gate.sh",
|
||
)
|
||
)
|
||
CI_GATE_DEFAULT_REPO_ROOT = os.environ.get("CI_GATE_DEFAULT_REPO_ROOT", "").strip()
|
||
CI_GATE_TIMEOUT_S = int(os.environ.get("CI_GATE_TIMEOUT_S", "1800"))
|
||
|
||
# ─── Importing project helpers (sibling .py files, not a package) ──
|
||
# ``tools/`` is not a Python package — sibling files are loaded via
|
||
# the project's existing ``_loader`` helper which the dispatchers use
|
||
# for the same reason. We follow the same pattern so the MCP runs
|
||
# either from the project venv or via ``opencode.json``'s explicit
|
||
# python path without ``PYTHONPATH`` plumbing.
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
from _diff_aware_gate import parse_failing_scenarios, parse_gate_statuses # noqa: E402
|
||
from _mcp_common import ( # noqa: E402
|
||
ForgejoCfg, bootstrap_loader, 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")
|
||
_ci_logs = load_sibling("_ci_logs", "_ci_logs.py")
|
||
|
||
# ─── Server ────────────────────────────────────────────────────────
|
||
server = FastMCP("ci")
|
||
|
||
|
||
# ─── Failure parsers ───────────────────────────────────────────────
|
||
# Each parser returns ``list[dict]`` shaped uniformly so the agent
|
||
# can iterate without per-format knowledge. Common keys:
|
||
# ``kind``: "pytest" | "ruff" | "mypy" | "behave"
|
||
# ``file``: source file (relative to repo root)
|
||
# ``line``: int (line number; missing for whole-test-suite failures)
|
||
# ``test``: str (e.g. "tests/foo.py::test_bar"; ruff/mypy omit)
|
||
# ``message``: short single-line summary
|
||
# Parsers are best-effort; an empty list means "couldn't find any
|
||
# structured failures" (not "no failures") — combine with the gate
|
||
# status to decide what the agent should do.
|
||
|
||
# pytest's short summary block:
|
||
# FAILED tests/auto_agents/test_x.py::test_y - AssertionError: ...
|
||
_PYTEST_FAILED_RE = re.compile(
|
||
r"^FAILED\s+(?P<file>[\w./\-]+?)::(?P<test>[\w\[\]:.\-]+)"
|
||
r"(?:\s+-\s+(?P<message>.+))?$",
|
||
re.MULTILINE,
|
||
)
|
||
|
||
# ruff's standard output:
|
||
# path/to/file.py:42:5: E501 line too long (123 > 120)
|
||
_RUFF_RE = re.compile(
|
||
r"^(?P<file>[\w./\-]+):(?P<line>\d+):\d+:\s+(?P<code>[A-Z]\d+)\s+(?P<message>.+)$",
|
||
re.MULTILINE,
|
||
)
|
||
|
||
# mypy / pyright error lines:
|
||
# path/to/file.py:42: error: ...
|
||
_MYPY_RE = re.compile(
|
||
r"^(?P<file>[\w./\-]+):(?P<line>\d+):(?:\d+:)?\s+(?P<level>error|warning):\s+(?P<message>.+)$",
|
||
re.MULTILINE | re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _parse_pytest_failures(output: str) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
for m in _PYTEST_FAILED_RE.finditer(output or ""):
|
||
key = (m.group("file"), m.group("test"))
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(
|
||
{
|
||
"kind": "pytest",
|
||
"file": m.group("file"),
|
||
"test": m.group("test"),
|
||
"message": (m.group("message") or "").strip()[:200],
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _parse_ruff_failures(output: str) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
seen: set[tuple[str, int, str]] = set()
|
||
for m in _RUFF_RE.finditer(output or ""):
|
||
try:
|
||
line = int(m.group("line"))
|
||
except ValueError:
|
||
continue
|
||
key = (m.group("file"), line, m.group("code"))
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(
|
||
{
|
||
"kind": "ruff",
|
||
"file": m.group("file"),
|
||
"line": line,
|
||
"code": m.group("code"),
|
||
"message": m.group("message").strip()[:200],
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _parse_mypy_failures(output: str) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
seen: set[tuple[str, int, str]] = set()
|
||
for m in _MYPY_RE.finditer(output or ""):
|
||
try:
|
||
line = int(m.group("line"))
|
||
except ValueError:
|
||
continue
|
||
msg = m.group("message").strip()
|
||
key = (m.group("file"), line, msg[:60])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(
|
||
{
|
||
"kind": "mypy",
|
||
"file": m.group("file"),
|
||
"line": line,
|
||
"level": m.group("level").lower(),
|
||
"message": msg[:200],
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _parse_behave_failures(output: str) -> list[dict[str, Any]]:
|
||
"""Wraps the existing ``_diff_aware_gate.parse_failing_scenarios``
|
||
so the MCP returns the same shape as the other parsers."""
|
||
return [
|
||
{
|
||
"kind": "behave",
|
||
"file": item["path"],
|
||
"line": int(item["line"]),
|
||
}
|
||
for item in parse_failing_scenarios(output)
|
||
]
|
||
|
||
|
||
def _extract_all_failures(output: str) -> list[dict[str, Any]]:
|
||
"""Run every parser. Each gate produces output in one of these
|
||
formats; the parsers that don't match return empty lists."""
|
||
return (
|
||
_parse_pytest_failures(output)
|
||
+ _parse_ruff_failures(output)
|
||
+ _parse_mypy_failures(output)
|
||
+ _parse_behave_failures(output)
|
||
)
|
||
|
||
|
||
# ─── Tools ─────────────────────────────────────────────────────────
|
||
|
||
|
||
@server.tool()
|
||
def run_local_gate(
|
||
gate: str | None = None,
|
||
repo_root: str | None = None,
|
||
fast: bool = False,
|
||
posargs: list[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Run ``local_ci_gate.sh`` and return a structured result.
|
||
|
||
Parameters:
|
||
gate: one of ``lint``, ``typecheck``, ``unit_tests``,
|
||
``integration_tests``, ``e2e_tests``, ``coverage_report``.
|
||
Omit to run the full gate set (or with ``fast=True``, the
|
||
cheap-gates subset).
|
||
repo_root: passed to the gate wrapper's ``--repo-root``. Omit
|
||
to let the wrapper resolve from cwd (looks up for noxfile.py).
|
||
The MCP's ``CI_GATE_DEFAULT_REPO_ROOT`` env var supplies the
|
||
fallback when neither is set — useful when the gate is run
|
||
by a worker whose cwd isn't the project root.
|
||
fast: pass ``--fast`` (skips e2e_tests + coverage_report).
|
||
Ignored when ``gate`` is set (single-gate runs are inherently
|
||
fast).
|
||
posargs: extra args after ``--`` (forwarded to nox session.posargs).
|
||
Requires ``gate`` to be set; the wrapper rejects pass-through
|
||
in multi-gate mode.
|
||
|
||
Returns:
|
||
``{status, gate_statuses, failures, elapsed_s, raw_tail,
|
||
command, exit_code}``. ``status`` is ``"pass"`` if exit==0,
|
||
``"fail"`` if exit==1, ``"error"`` for argument/setup errors
|
||
(exit==2 or subprocess failure). ``raw_tail`` is the last 80
|
||
lines of combined output — included so the agent can grep when
|
||
the parsers missed something. ``command`` is the exact argv for
|
||
reproducibility.
|
||
"""
|
||
if not CI_GATE_SCRIPT.is_file():
|
||
return {
|
||
"status": "error",
|
||
"error": f"gate wrapper not found at {CI_GATE_SCRIPT}",
|
||
"gate_statuses": {},
|
||
"failures": [],
|
||
"elapsed_s": 0.0,
|
||
"raw_tail": "",
|
||
"command": [],
|
||
"exit_code": -1,
|
||
}
|
||
|
||
cmd: list[str] = ["bash", str(CI_GATE_SCRIPT)]
|
||
if gate:
|
||
cmd += ["--gate", gate]
|
||
elif fast:
|
||
cmd += ["--fast"]
|
||
|
||
effective_repo_root = (repo_root or "").strip() or CI_GATE_DEFAULT_REPO_ROOT
|
||
if effective_repo_root:
|
||
cmd += ["--repo-root", effective_repo_root]
|
||
|
||
if posargs:
|
||
if not gate:
|
||
return {
|
||
"status": "error",
|
||
"error": "posargs requires `gate` (multi-gate pass-through is undefined)",
|
||
"gate_statuses": {},
|
||
"failures": [],
|
||
"elapsed_s": 0.0,
|
||
"raw_tail": "",
|
||
"command": cmd,
|
||
"exit_code": -1,
|
||
}
|
||
cmd += ["--"] + [str(a) for a in posargs]
|
||
|
||
started = time.monotonic()
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=CI_GATE_TIMEOUT_S,
|
||
check=False,
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
return {
|
||
"status": "error",
|
||
"error": f"gate timed out after {CI_GATE_TIMEOUT_S}s",
|
||
"gate_statuses": {},
|
||
"failures": [],
|
||
"elapsed_s": time.monotonic() - started,
|
||
"raw_tail": (exc.stderr or "")[-4000:],
|
||
"command": cmd,
|
||
"exit_code": -1,
|
||
}
|
||
elapsed = time.monotonic() - started
|
||
|
||
combined = (result.stdout or "") + "\n" + (result.stderr or "")
|
||
gate_statuses = parse_gate_statuses(combined)
|
||
failures = _extract_all_failures(combined)
|
||
|
||
if result.returncode == 0:
|
||
status = "pass"
|
||
elif result.returncode == 1:
|
||
status = "fail"
|
||
else:
|
||
status = "error"
|
||
|
||
return {
|
||
"status": status,
|
||
"gate_statuses": gate_statuses,
|
||
"failures": failures,
|
||
"elapsed_s": round(elapsed, 2),
|
||
"raw_tail": "\n".join(combined.splitlines()[-80:]),
|
||
"command": cmd,
|
||
"exit_code": result.returncode,
|
||
}
|
||
|
||
|
||
@server.tool()
|
||
def fetch_pr_check_summary(pr: int) -> dict[str, Any]:
|
||
"""Per-check status for the PR's HEAD SHA.
|
||
|
||
Wraps Forgejo's paginated ``/commits/{sha}/statuses`` (NOT the
|
||
combined ``/status``) via the project's existing
|
||
``_review_fetch.fetch_ci_check_detail`` helper, then projects
|
||
each status to a compact ``{name, state, url, description}`` row.
|
||
|
||
Returns:
|
||
``{pr, head_sha, checks: [...], complete}`` where ``complete``
|
||
is ``True`` if pagination returned all checks and ``False`` if
|
||
Forgejo truncated. ``state`` is one of ``success``, ``failure``,
|
||
``error``, ``pending``. ``url`` is the per-check log target
|
||
(``target_url`` from Forgejo) — agent can open the log via
|
||
future ``fetch_check_failure_slice`` (not yet implemented).
|
||
"""
|
||
cfg = ForgejoCfg()
|
||
err = require_token(cfg, "default")
|
||
if err:
|
||
return {
|
||
"error": err,
|
||
"pr": pr,
|
||
"head_sha": None,
|
||
"checks": [],
|
||
"complete": False,
|
||
}
|
||
|
||
# Resolve PR -> head SHA first.
|
||
try:
|
||
pr_resp = _claim_runtime.get(
|
||
f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr)}", cfg
|
||
)
|
||
except Exception as exc:
|
||
return {
|
||
"error": f"PR fetch failed: {exc!r}",
|
||
"pr": pr,
|
||
"head_sha": None,
|
||
"checks": [],
|
||
"complete": False,
|
||
}
|
||
if int(pr_resp.get("status") or 0) != 200:
|
||
return {
|
||
"error": f"PR fetch returned HTTP {pr_resp.get('status')}",
|
||
"pr": pr,
|
||
"head_sha": None,
|
||
"checks": [],
|
||
"complete": False,
|
||
}
|
||
pr_body = pr_resp.get("body") or {}
|
||
head_sha = (pr_body.get("head") or {}).get("sha") or ""
|
||
if not head_sha:
|
||
return {
|
||
"error": "PR has no head SHA (closed/deleted branch?)",
|
||
"pr": pr,
|
||
"head_sha": None,
|
||
"checks": [],
|
||
"complete": False,
|
||
}
|
||
|
||
try:
|
||
statuses, complete = _review_fetch.fetch_ci_check_detail(cfg, head_sha)
|
||
except Exception as exc:
|
||
return {
|
||
"error": f"check-detail fetch failed: {exc!r}",
|
||
"pr": pr,
|
||
"head_sha": head_sha,
|
||
"checks": [],
|
||
"complete": False,
|
||
}
|
||
|
||
# Forgejo's /commits/{sha}/statuses can return multiple entries per
|
||
# context (one per push); we keep only the most-recent per name to
|
||
# match what the worker actually cares about (current state).
|
||
latest_by_name: dict[str, dict[str, Any]] = {}
|
||
for s in statuses:
|
||
name = s.get("context") or "(unnamed)"
|
||
prior = latest_by_name.get(name)
|
||
if prior is None or (s.get("created_at") or "") > (
|
||
prior.get("_created_at") or ""
|
||
):
|
||
latest_by_name[name] = {
|
||
"name": name,
|
||
"state": s.get("status") or s.get("state") or "unknown",
|
||
"url": s.get("target_url") or "",
|
||
"description": (s.get("description") or "").strip()[:200],
|
||
"_created_at": s.get("created_at") or "",
|
||
}
|
||
checks = [
|
||
{k: v for k, v in row.items() if not k.startswith("_")}
|
||
for row in sorted(latest_by_name.values(), key=lambda r: r["name"])
|
||
]
|
||
|
||
return {
|
||
"pr": pr,
|
||
"head_sha": head_sha,
|
||
"checks": checks,
|
||
"complete": complete,
|
||
}
|
||
|
||
|
||
@server.tool()
|
||
def fetch_pr_failure_logs(pr: int) -> dict[str, Any]:
|
||
"""Per-failing-job log tails for the PR's HEAD SHA.
|
||
|
||
Wraps the shared :mod:`_ci_logs` cache that the dispatcher uses
|
||
at pre-fetch time — calling this tool is FREE (cache hit) after
|
||
the dispatcher has already populated it for the current SHA.
|
||
Use this when:
|
||
|
||
- Your prompt's ``## Pre-fetched CI failure logs`` section has
|
||
``fetch_error`` for the job you care about (the dispatcher's
|
||
live attempt failed; you can retry now from a different
|
||
process / network path).
|
||
- The dispatcher skipped pre-fetch entirely (CI status was
|
||
``pending`` when the prompt was built but has since gone red).
|
||
- You want to re-read a log tail without re-loading the prompt
|
||
section.
|
||
|
||
DO NOT use ``bash curl`` or ``webfetch`` against the Forgejo
|
||
Actions API for log content — both are blocked by the worker's
|
||
permission allowlist AND would bypass the shared per-SHA cache.
|
||
|
||
Returns:
|
||
``{pr, head_sha, failing_jobs: [...], completed, source}``
|
||
where each failing job carries
|
||
``{context, state, run_id, job_id, log_url, log_tail,
|
||
log_bytes_seen, log_truncated, fetch_error}``.
|
||
``source`` is one of ``"cache" | "live" | "stale" | "disabled"``.
|
||
On error: ``{error: str, pr, head_sha}``.
|
||
"""
|
||
try:
|
||
pr_int = int(pr)
|
||
except (TypeError, ValueError):
|
||
return {"error": f"pr must be an integer, got {pr!r}", "pr": pr}
|
||
if pr_int <= 0:
|
||
return {"error": f"pr must be positive, got {pr_int}", "pr": pr_int}
|
||
cfg = ForgejoCfg()
|
||
err = require_token(cfg, "default")
|
||
if err:
|
||
return {"error": err, "pr": pr_int, "head_sha": None}
|
||
# Resolve PR → head_sha via Forgejo. (Same shape as
|
||
# ``fetch_pr_check_summary`` above; could be factored out to
|
||
# ``_mcp_common`` in a follow-up — both tools need it.)
|
||
try:
|
||
pr_resp = _claim_runtime.get(
|
||
f"/repos/{cfg.owner}/{cfg.repo}/pulls/{pr_int}", cfg,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
return {
|
||
"error": f"PR fetch failed: {exc!r}",
|
||
"pr": pr_int, "head_sha": None,
|
||
}
|
||
if int(pr_resp.get("status") or 0) != 200:
|
||
return {
|
||
"error": f"PR fetch returned HTTP {pr_resp.get('status')}",
|
||
"pr": pr_int, "head_sha": None,
|
||
}
|
||
pr_body = pr_resp.get("body") or {}
|
||
head_sha = (pr_body.get("head") or {}).get("sha") or ""
|
||
if not head_sha:
|
||
return {
|
||
"error": "PR has no head SHA (closed/deleted branch?)",
|
||
"pr": pr_int, "head_sha": None,
|
||
}
|
||
# Pre-call cache probe so the result can carry an honest
|
||
# ``source`` label without instrumenting ``fetch_pr_failure_logs``
|
||
# (kept tight for the dispatcher's hot path).
|
||
import datetime as _dt
|
||
pre_cache = _ci_logs._read_cache(head_sha)
|
||
now_dt = _dt.datetime.now(_dt.timezone.utc)
|
||
in_backoff = _ci_logs._backoff_active(pre_cache, now_dt)
|
||
try:
|
||
payload, completed = _ci_logs.fetch_pr_failure_logs(
|
||
cfg, head_sha,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
return {
|
||
"error": f"fetch_pr_failure_logs raised: {exc!r}",
|
||
"pr": pr_int, "head_sha": head_sha,
|
||
}
|
||
if _ci_logs.is_disabled():
|
||
source = "disabled"
|
||
elif in_backoff:
|
||
source = "stale"
|
||
elif pre_cache is None or not bool(pre_cache.get("completed")):
|
||
source = "live"
|
||
else:
|
||
source = "cache"
|
||
return {
|
||
"pr": pr_int,
|
||
"head_sha": head_sha,
|
||
"failing_jobs": payload.get("failing_jobs") or [],
|
||
"completed": bool(completed),
|
||
"source": source,
|
||
}
|
||
|
||
|
||
main = make_main(server, "mcp_ci_server")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|