fastmcp/scripts/maintenance_status.py
nate nowack aa6e92bc2a
status: unify dashboard and terminal issue ranking (#5300)
* status: add maintainer dashboard and main CI evidence

* Refine maintainer dashboard with activity and queue evidence

* Unify terminal and dashboard issue ranking

* Bound scheduled ranking cost and reuse public assessments
2026-09-27 17:46:55 -05:00

591 lines
20 KiB
Python

#!/usr/bin/env python
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
Public maintenance status for FastMCP.
Writes `status.json` (schema `fastmcp-maintenance/1`), `STATUS.md`, and a
shields.io `badge.json` into the output directory. Automation health comes
from each workflow's own GitHub Actions runs, so every claim links to public
evidence. Only facts are published: the contributor queue appears as how many
PRs wait and for how long. Judgments about individual PRs, even as counts,
stay private (`uv run scripts/gate_digest.py`).
Entries published by other systems are merged in from OPERATOR_STATUS_URL
when it is set, each keeping its own `as_of`.
Requires `gh` authenticated with read access.
"""
import json
import os
import shutil
import subprocess
import sys
import urllib.request
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
UTC = timezone.utc
REPO = "PrefectHQ/fastmcp"
SCHEMA = "fastmcp-maintenance/1"
WINDOW_DAYS = 7
STATUS_PAGE = f"https://github.com/{REPO}/blob/status/STATUS.md"
# What each GitHub automation does, checked against its workflow's triggers.
AUTOMATIONS = [
{
"id": "issue-link-gate",
"name": "issue-link gate",
"what": "holds an external PR until its author is assigned to an issue it links; assignment reopens a gate-closed PR. A failed run is usually a PR it blocked, so this row shows only that the gate is running",
"cadence": "on each external PR change and each issue assignment",
"workflows": ["require-issue-link.yml"],
# A failed run is the gate blocking a PR, not the gate breaking.
"outcomes": ("passed", "blocked"),
},
{
"id": "label-triage",
"name": "labeling",
"what": "labels each new issue and PR, and flags submissions that don't follow CONTRIBUTING.md",
"cadence": "on each new issue or PR",
"workflows": ["marvin-label-triage.yml"],
},
{
"id": "dedupe",
"name": "duplicate detection",
"what": "points out when a new issue duplicates an existing one",
"cadence": "on each new issue",
"workflows": ["marvin-dedupe-issues.yml"],
},
{
"id": "auto-close",
"name": "auto-close",
"what": "closes issues marked as duplicates, and issues still missing a reproducible example after 7 days of author inactivity",
"cadence": "daily",
"workflows": ["auto-close-duplicates.yml", "auto-close-needs-mre.yml"],
},
{
"id": "bug-investigation",
"name": "bug investigation",
"what": "investigates new bug reports filed by maintainers and posts findings",
"cadence": "on each qualifying new issue",
"workflows": ["marvin-triage-issue.yml"],
},
{
"id": "ci-failure-analysis",
"name": "CI failure analysis",
"what": "explains failed test and static-analysis runs on pull requests",
"cadence": "after each failed PR run",
"workflows": ["marvin-test-failure.yml"],
},
{
"id": "maintainer-commands",
"name": "maintainer commands",
"what": "`/marvin` on an issue or PR asks the bot to act; `/tidy` hides resolved review threads",
"cadence": "on a maintainer's comment",
"workflows": [
"marvin-comment-on-issue.yml",
"marvin-comment-on-pr.yml",
"minimize-resolved-reviews.yml",
],
},
{
"id": "upgrade-checks",
"name": "upgrade checks",
"what": "runs the test suite against the newest dependency releases and opens an issue when it fails",
"cadence": "nightly",
"workflows": ["run-upgrade-checks.yml"],
},
{
"id": "release-publish",
"name": "release publishing",
"what": "publishes fastmcp-slim, then fastmcp-tasks, fastmcp-remote, and fastmcp to PyPI when a release is cut",
"cadence": "on each release",
"workflows": [
"publish-fastmcp-slim.yml",
"publish-fastmcp-tasks.yml",
"publish-fastmcp-remote.yml",
"publish-fastmcp.yml",
],
},
{
"id": "docs-deploy",
"name": "docs deploy",
"what": "deploys gofastmcp.com when the published docs change",
"cadence": "on each docs publication",
"workflows": ["deploy-docs.yml"],
},
]
JUDGMENT = [
"whether to assign an external contributor, which reopens their gated PR",
"merging, and marking agent-opened draft PRs ready",
"changes to documented behavior or compatibility, and any API addition",
"cutting a release, and its title and notes",
"classifying and disclosing security reports",
]
def gh_json(*args: str):
out = subprocess.run(["gh", *args], capture_output=True, text=True, check=True)
return json.loads(out.stdout)
MAIN_WORKFLOWS = (
"Tests",
"Run static analysis",
"CodeQL",
"Downstream smoke",
"Schema Crash Test",
)
def main_health() -> dict:
"""Report main checks, including GitHub's automatic CodeQL runs."""
commit = gh_json("api", f"repos/{REPO}/commits/main")
sha = commit["sha"]
runs = []
for event in ("push", "dynamic"):
runs.extend(
gh_json(
"api",
"-X",
"GET",
f"repos/{REPO}/actions/runs",
"-f",
"branch=main",
"-f",
f"event={event}",
"-f",
f"head_sha={sha}",
"-f",
"per_page=100",
)["workflow_runs"]
)
checks = []
for name in MAIN_WORKFLOWS:
matching = [
r
for r in runs
if (
r["name"] == name
or (
name == "CodeQL"
and r.get("path") == "dynamic/github-code-scanning/codeql"
)
)
and r["head_sha"] == sha
and r.get("event") in {"push", "dynamic"}
]
run = max(matching, key=lambda r: r["id"]) if matching else None
if run is None and name in {"Downstream smoke", "Schema Crash Test"}:
continue
if run is None:
state = "unknown"
elif run["status"] != "completed":
state = "pending"
elif run["conclusion"] == "success":
state = "ok"
elif run["conclusion"] in {
"failure",
"timed_out",
"startup_failure",
"action_required",
}:
state = "failed"
else:
state = "unknown"
checks.append(
{"name": name, "state": state, "url": run["html_url"] if run else None}
)
return {"sha": sha, "url": commit["html_url"], "checks": checks}
def one_workflow(file: str, since: date) -> dict:
"""State, last success, and outcome counts for one workflow over the window."""
enabled = (
gh_json("api", f"repos/{REPO}/actions/workflows/{file}")["state"] == "active"
)
out = subprocess.run(
[
"gh",
"api",
"--paginate",
"-X",
"GET",
f"repos/{REPO}/actions/workflows/{file}/runs",
"-f",
f"created=>={since.isoformat()}",
"-f",
"per_page=100",
"--jq",
".workflow_runs[] | {conclusion, created_at}",
],
capture_output=True,
text=True,
check=True,
).stdout
runs = [json.loads(line) for line in out.splitlines() if line.strip()]
finished = ("success", "failure", "timed_out", "startup_failure", "action_required")
done = [r for r in runs if r["conclusion"] in finished]
ok = [r for r in done if r["conclusion"] == "success"]
# The current state comes from the latest finished run even when it is
# older than the window, so a failure doesn't age into "idle".
latest = gh_json(
"api",
"-X",
"GET",
f"repos/{REPO}/actions/workflows/{file}/runs",
"-f",
"status=completed",
"-f",
"per_page=30",
"--jq",
"[.workflow_runs[] | {conclusion, created_at}]",
)
latest = [r for r in latest if r["conclusion"] in finished]
last_ok = next((r for r in latest if r["conclusion"] == "success"), None)
if not enabled:
state = "off"
elif not latest:
state = "idle"
elif latest[0]["conclusion"] != "success":
state = "degraded"
elif not done:
state = "idle"
else:
state = "ok"
return {
"state": state,
"last_ok_day": last_ok["created_at"][:10] if last_ok else None,
"succeeded": len(ok),
"failed": len(done) - len(ok),
"daily": daily_runs(done, since),
}
def daily_runs(runs: list[dict], since: date) -> list[dict]:
days = []
for offset in range((datetime.now(UTC).date() - since).days + 1):
day = (since + timedelta(days=offset)).isoformat()
matching = [r for r in runs if r["created_at"][:10] == day]
days.append(
{
"day": day,
"succeeded": sum(r["conclusion"] == "success" for r in matching),
"failed": sum(r["conclusion"] != "success" for r in matching),
}
)
return days
def workflow_health(
files: list[str], since: date, outcomes: tuple[str, str] | None = None
) -> dict:
"""Combine a group's workflows; any failing or disabled member degrades it.
With `outcomes`, a failed run is a legitimate result (the gate blocking a
PR), so the workflow is ok whenever it ran, and counts use those names.
"""
members = [one_workflow(f, since) for f in files]
if outcomes:
for m in members:
if m["state"] == "degraded":
m["state"] = "ok"
states = {m["state"] for m in members}
if states == {"off"}:
state = "off"
elif "degraded" in states or "off" in states:
state = "degraded"
elif states == {"idle"}:
state = "idle"
else:
state = "ok"
days = [m["last_ok_day"] for m in members if m["last_ok_day"]]
daily = {}
for member in members:
for bucket in member["daily"]:
row = daily.setdefault(
bucket["day"],
{"day": bucket["day"], "succeeded": 0, "failed": 0, "blocked": 0},
)
row["succeeded"] += bucket["succeeded"]
row["blocked" if outcomes else "failed"] += bucket["failed"]
return {
"state": state,
"last_ok_day": max(days) if days else None,
"daily": list(daily.values()),
"counts": {
(outcomes or ("succeeded", "failed"))[0]: sum(
m["succeeded"] for m in members
),
(outcomes or ("succeeded", "failed"))[1]: sum(m["failed"] for m in members),
},
}
def contributor_queue() -> dict:
"""How many external PRs wait on the gate, and for how long. Facts only."""
prs = gh_json(
"pr",
"list",
"--repo",
REPO,
"--state",
"open",
"--label",
"missing-issue-link",
"--limit",
"500",
"--json",
"createdAt,number,title,url",
)
now = datetime.now(UTC)
ages = [
(now - datetime.fromisoformat(p["createdAt"].replace("Z", "+00:00"))).days
for p in prs
]
return {
"items": [
{
"number": p["number"],
"title": p["title"],
"url": p["url"],
"age_days": (
now - datetime.fromisoformat(p["createdAt"].replace("Z", "+00:00"))
).days,
}
for p in sorted(prs, key=lambda p: p["createdAt"])[:10]
],
"id": "contributor-queue",
"name": "contributor queue",
"what": "external PRs waiting for their author to be assigned to a linked issue",
"cadence": "twice daily",
"runs_on": "github-actions",
"state": "ok",
"last_ok_day": date.today().isoformat(),
"evidence_url": f"https://github.com/{REPO}/pulls?q=is%3Apr+is%3Aopen+label%3Amissing-issue-link",
"window": "now",
"counts": {
"waiting": len(prs),
"oldest_days": max(ages, default=0),
"waiting_over_7_days": sum(age > 7 for age in ages),
},
}
OPERATOR_STATES = {"ok", "idle", "degraded", "off"}
OPERATOR_RUNS_ON = {"operator-server", "operator-laptop"}
OPERATOR_TEXT = ("id", "name", "what", "cadence", "window")
EVIDENCE_PREFIX = f"https://github.com/{REPO}/"
STALE_AFTER = timedelta(days=2)
def _text(value: object) -> str:
"""One table-safe line: no pipes or line breaks from another publisher."""
return " ".join(str(value).replace("|", "/").split())[:300]
def _operator_entry(raw: dict, as_of: str, publisher: str, stale: bool) -> dict | None:
"""Keep only the schema's fields from another system's entry."""
if (
raw.get("state") not in OPERATOR_STATES
or raw.get("runs_on") not in OPERATOR_RUNS_ON
):
return None
entry = {k: _text(raw.get(k, "")) for k in OPERATOR_TEXT}
evidence = raw.get("evidence_url")
counts = raw.get("counts") if isinstance(raw.get("counts"), dict) else {}
entry.update(
runs_on=raw["runs_on"],
# A publisher that stopped reporting shows as idle, not ok; laptop-bound
# work can be legitimately quiet, so it isn't called degraded.
state="idle" if stale else raw["state"],
stale=stale,
last_ok_day=_text(raw["last_ok_day"])[:10] if raw.get("last_ok_day") else None,
evidence_url=evidence
if isinstance(evidence, str) and evidence.startswith(EVIDENCE_PREFIX)
else None,
counts={
_text(k): v
for k, v in counts.items()
if isinstance(v, int) and not isinstance(v, bool)
},
as_of=as_of,
publisher=publisher,
)
return entry
def operator_entries() -> list[dict]:
"""Entries another system publishes about itself, or one marker if it can't be read."""
url = os.environ.get("OPERATOR_STATUS_URL")
if not url:
return []
try:
with urllib.request.urlopen(url, timeout=10) as resp:
doc = json.load(resp)
if not isinstance(doc, dict) or not isinstance(doc.get("automations"), list):
raise ValueError("operator status is not a status document")
if doc.get("schema") != SCHEMA:
raise ValueError(f"unexpected schema {doc.get('schema')!r}")
as_of = _text(doc["as_of"])
publisher = _text(doc["publisher"])
age = datetime.now(UTC) - datetime.fromisoformat(as_of.replace("Z", "+00:00"))
stale = age > STALE_AFTER
entries = [
_operator_entry(raw, as_of, publisher, stale)
for raw in doc["automations"]
if isinstance(raw, dict)
]
return [e for e in entries if e is not None]
except (OSError, ValueError, KeyError, TypeError, AttributeError) as e:
print(f"operator status unavailable: {e}", file=sys.stderr)
return [
{
"id": "operator-status",
"name": "maintainer-run automations",
"what": "status published by automations that run outside GitHub Actions",
"cadence": "twice daily",
"runs_on": "operator-server",
"state": "degraded",
"last_ok_day": None,
"evidence_url": None,
"window": "now",
"counts": {},
}
]
def build() -> dict:
since = datetime.now(UTC).date() - timedelta(days=WINDOW_DAYS - 1)
automations = []
for a in AUTOMATIONS:
try:
h = workflow_health(a["workflows"], since, a.get("outcomes"))
except (subprocess.CalledProcessError, ValueError, KeyError) as e:
print(f"{a['id']}: could not read workflow runs: {e}", file=sys.stderr)
h = {"state": "degraded", "last_ok_day": None, "counts": {}}
automations.append(
{
"id": a["id"],
"name": a["name"],
"what": a["what"],
"cadence": a["cadence"],
"runs_on": "github-actions",
"state": h["state"],
"last_ok_day": h["last_ok_day"],
"evidence_url": f"https://github.com/{REPO}/actions/workflows/{a['workflows'][0]}",
"window": f"{WINDOW_DAYS}d",
"counts": h["counts"],
"daily": h.get("daily", []),
}
)
try:
automations.append(contributor_queue())
except (subprocess.CalledProcessError, ValueError, KeyError) as e:
print(f"contributor-queue: {e}", file=sys.stderr)
try:
main = main_health()
except (subprocess.CalledProcessError, ValueError, KeyError) as e:
print(f"main: could not read checks: {e}", file=sys.stderr)
main = None
return {
"schema": SCHEMA,
"publisher": "fastmcp-actions",
"as_of": datetime.now(UTC)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z"),
"automations": automations + operator_entries(),
"main": main,
"needs_judgment": JUDGMENT,
"docs": {
"maintaining": f"https://github.com/{REPO}/blob/main/docs/development/contributing.mdx#maintenance-and-automation",
"agents": f"https://github.com/{REPO}/blob/main/AGENTS.md",
},
}
def overall(doc: dict) -> str:
mine = [
a["state"] for a in doc["automations"] if a.get("runs_on") == "github-actions"
]
return "degraded" if "degraded" in mine else "ok"
def markdown(doc: dict) -> str:
lines = [
"# FastMCP maintenance status",
"",
f"As of {doc['as_of']}. Generated twice a day by "
f"[maintenance-status](https://github.com/{REPO}/actions/workflows/maintenance-status.yml); "
"machine-readable as [status.json](status.json). "
f"How the project is run: [Development guide]({doc['docs']['maintaining']}).",
"",
"| automation | state | last ok | runs on | cadence |",
"|---|---|---|---|---|",
]
for a in doc["automations"]:
name = (
f"[{a['name']}]({a['evidence_url']})"
if a.get("evidence_url")
else a["name"]
)
state = a["state"] + (" (stale)" if a.get("stale") else "")
lines.append(
f"| {name} | {state} | {a.get('last_ok_day') or '—'} | {a['runs_on']} | {a['cadence']} |"
)
queue = next(
(a["counts"] for a in doc["automations"] if a["id"] == "contributor-queue"),
None,
)
lines += [""]
if queue:
lines += [
f"**Contributor queue:** {queue['waiting']} PRs waiting on assignment; "
f"the oldest has waited {queue['oldest_days']} days, and "
f"{queue['waiting_over_7_days']} have waited more than a week.",
"",
]
lines += [
"**Needs a maintainer's judgment:**",
"",
*[f"- {j}" for j in doc["needs_judgment"]],
"",
"States: `ok` last run succeeded; `idle` nothing to do in the window; "
"`degraded` last run failed; `off` disabled.",
]
return "\n".join(lines) + "\n"
def badge(doc: dict) -> dict:
state = overall(doc)
return {
"schemaVersion": 1,
"label": "maintenance",
"message": state,
"color": "brightgreen" if state == "ok" else "orange",
}
def main() -> None:
out = Path(sys.argv[1] if len(sys.argv) > 1 else "status-out")
out.mkdir(parents=True, exist_ok=True)
doc = build()
if len(sys.argv) > 2:
doc["attention"] = json.loads(Path(sys.argv[2]).read_text())
(out / "status.json").write_text(json.dumps(doc, indent=2) + "\n")
(out / "STATUS.md").write_text(markdown(doc))
(out / "badge.json").write_text(json.dumps(badge(doc)) + "\n")
site = Path(__file__).resolve().parent.parent / "status-site"
for name in ("index.html", "style.css", "app.js"):
shutil.copyfile(site / name, out / name)
print(markdown(doc))
if __name__ == "__main__":
main()