mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-09 06:23:20 +02:00
* 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
464 lines
16 KiB
Python
464 lines
16 KiB
Python
#!/usr/bin/env python
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "httpx",
|
|
# "pydantic>=2",
|
|
# "typesafe-sdk>=0.6",
|
|
# "openai>=1.60",
|
|
# "textual>=1.0",
|
|
# ]
|
|
# ///
|
|
"""Browse the issue ranking from rank_issues.py in a terminal UI.
|
|
|
|
Takes the same options as rank_issues.py (--repo, --judge, --limit, ...).
|
|
|
|
Keys: up/down select, o or enter open the issue, a open the author,
|
|
links in the detail pane open in the browser, k cycle the kind filter,
|
|
u show only issues without a maintainer reply, m load the next page,
|
|
r re-rank from the newest, q quit.
|
|
|
|
Usage:
|
|
uv run scripts/rank_issues_tui.py --judge jev --limit 100
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
import webbrowser
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
from rank_issues import ( # noqa: E402
|
|
KINDS,
|
|
MAINTAINER_ASSOCIATIONS,
|
|
Config,
|
|
Ranked,
|
|
parse_args,
|
|
rank,
|
|
)
|
|
from rich.text import Text # noqa: E402
|
|
from textual import work # noqa: E402
|
|
from textual.app import App, ComposeResult # noqa: E402
|
|
from textual.binding import Binding # noqa: E402
|
|
from textual.containers import Horizontal, Vertical, VerticalScroll # noqa: E402
|
|
from textual.widgets import DataTable, Footer, Markdown, Static # noqa: E402
|
|
|
|
BG = "#0d0807"
|
|
PANEL = "#171009"
|
|
SCAN = "#1d130b"
|
|
INK = "#ffd9b8"
|
|
MUTED = "#9c6f52"
|
|
BRIGHT = "#ff7a45"
|
|
TRACK = "#33200f"
|
|
BORDER = "#5c3322"
|
|
|
|
COMPONENT_LABELS = {
|
|
"severity": "reads as severe",
|
|
"security": "security-sensitive",
|
|
"regression": "likely regression",
|
|
"release_proximity": "right after a release it names",
|
|
"repro": "has a reproduction",
|
|
"actionable": "precise and actionable",
|
|
"spec": "MCP spec conformance",
|
|
"downstream": "breaks a downstream integration",
|
|
"author_trust": "trusted author",
|
|
"engagement": "others engaged",
|
|
"neglect": "no maintainer reply yet",
|
|
"handled": "assigned or has a linked PR",
|
|
"feature": "feature request",
|
|
"question": "usage question",
|
|
"low_effort": "low-effort report",
|
|
"spray": "author opens PRs across many repos",
|
|
"dismissed": "labeled invalid, duplicate, or wontfix",
|
|
"waiting": "waiting on the reporter",
|
|
"priority": "labeled high priority",
|
|
"deprioritized": "labeled low priority",
|
|
"security_label": "labeled security",
|
|
"feature_label": "labeled enhancement or proposal",
|
|
}
|
|
|
|
KIND_FILTERS = [None, *KINDS]
|
|
|
|
|
|
def score_bar(score: float, peak: float, width: int = 10) -> Text:
|
|
filled = round(width * max(score, 0) / peak) if peak > 0 else 0
|
|
return Text.assemble(("▓" * filled, BRIGHT), ("░" * (width - filled), TRACK))
|
|
|
|
|
|
ISSUE_REF = re.compile(r"(?<![\w/&\[])#(\d{2,6})\b")
|
|
FENCE = re.compile(r"(```.*?```|`[^`\n]*`)", re.DOTALL)
|
|
|
|
|
|
def plural(n: int, noun: str) -> str:
|
|
return f"{n} {noun}" + ("" if n == 1 else "s")
|
|
|
|
|
|
def label_query(label: str) -> str:
|
|
return quote('is:open label:"' + label + '"')
|
|
|
|
|
|
def link_issue_refs(text: str, repo: str) -> str:
|
|
"""Turn `#1234` references outside code into links (GitHub redirects an
|
|
issue URL to the pull request when the number is a PR)."""
|
|
parts = FENCE.split(text)
|
|
for i in range(0, len(parts), 2):
|
|
parts[i] = ISSUE_REF.sub(
|
|
lambda m: f"[#{m.group(1)}](https://github.com/{repo}/issues/{m.group(1)})",
|
|
parts[i],
|
|
)
|
|
return "".join(parts)
|
|
|
|
|
|
def top_kind(r: Ranked) -> tuple[str, float]:
|
|
if not r.judgment:
|
|
return "-", 0.0
|
|
return max(r.judgment.kind.items(), key=lambda item: item[1])
|
|
|
|
|
|
def detail_markdown(r: Ranked, config: Config) -> str:
|
|
issue, facts = r.issue, r.facts
|
|
gh = f"https://github.com/{config.repo}"
|
|
kind, kind_p = top_kind(r)
|
|
release = facts.get("release_before")
|
|
days = facts.get("days_after_release")
|
|
release_line = (
|
|
f"opened {days:.0f}d after [{release}]({gh}/releases/tag/{release})"
|
|
if release and days is not None
|
|
else "no release before it"
|
|
)
|
|
if facts.get("mentioned_version"):
|
|
release_line += f", reports {facts['mentioned_version']}"
|
|
labels = (
|
|
", ".join(
|
|
f"[{label}]({gh}/issues?q={label_query(label)})" for label in issue.labels
|
|
)
|
|
or "none"
|
|
)
|
|
|
|
why = sorted(
|
|
(
|
|
(name, value, config.weights.get(name, 0.0) * value)
|
|
for name, value in r.components.items()
|
|
),
|
|
key=lambda item: -abs(item[2]),
|
|
)
|
|
why_lines = [
|
|
f"- {'▲' if contribution > 0 else '▼'} {COMPONENT_LABELS.get(name, name)} "
|
|
f"`{value:.2f}` → `{contribution:+.1f}`"
|
|
for name, value, contribution in why
|
|
if abs(contribution) >= 0.1
|
|
]
|
|
|
|
author = facts.get("author") or {}
|
|
now = datetime.now(timezone.utc)
|
|
author_bits = []
|
|
if issue.author_type != "User":
|
|
author_bits.append("bot account")
|
|
if issue.author_association in MAINTAINER_ASSOCIATIONS:
|
|
author_bits.append("maintainer")
|
|
if issue.author_created_at:
|
|
author_bits.append(
|
|
f"account {(now - issue.author_created_at).days // 365}y old"
|
|
)
|
|
author_bits.append(f"{issue.author_followers} followers")
|
|
if author:
|
|
who = quote(f"author:{issue.author}")
|
|
author_bits.append(
|
|
f"[{plural(author['merged_prs_in_repo'], 'merged PR')} here]({gh}/pulls?q=is:pr+is:merged+{who})"
|
|
)
|
|
author_bits.append(
|
|
f"[{plural(author['issues_in_repo'], 'issue')} here]({gh}/issues?q=is:issue+{who})"
|
|
)
|
|
if author["unmerged_closed_prs_in_repo"]:
|
|
author_bits.append(
|
|
f"{author['unmerged_closed_prs_in_repo']} PRs closed unmerged"
|
|
)
|
|
author_bits.append(
|
|
f"{author['recent_prs']} PRs in {author['recent_pr_repos']} other repos "
|
|
f"in the last {config.spray_window_days}d"
|
|
)
|
|
|
|
others = sorted({c for c in issue.commenters if c != issue.author})
|
|
state_bits = [
|
|
"maintainer replied"
|
|
if facts.get("maintainer_replied")
|
|
else "**no maintainer reply**"
|
|
if facts.get("maintainer_replied") is False
|
|
else "reply history incomplete",
|
|
f"assigned to {', '.join(issue.assignees)}"
|
|
if issue.assignees
|
|
else "unassigned",
|
|
f"linked PRs {', '.join(f'#{n}' for n in issue.linked_prs)}"
|
|
if issue.linked_prs
|
|
else "no linked PR",
|
|
f"{issue.reactions} reactions",
|
|
f"{len(others)} other commenters",
|
|
]
|
|
|
|
judgment_line = ""
|
|
if r.judgment:
|
|
j = r.judgment
|
|
judgment_line = "## judgment\n\n" + " · ".join(
|
|
[
|
|
f"{kind} {kind_p:.0%}",
|
|
f"severity `{j.severity:.2f}`",
|
|
f"security `{j.security:.2f}`",
|
|
f"repro `{j.repro:.2f}`",
|
|
f"actionable `{j.actionable:.2f}`",
|
|
f"spec `{j.spec:.2f}`",
|
|
f"downstream `{j.downstream:.2f}`",
|
|
f"low effort `{j.low_effort:.2f}`",
|
|
]
|
|
)
|
|
|
|
body = link_issue_refs(issue.body.strip(), config.repo) or "_no description_"
|
|
if len(body) > config.body_chars:
|
|
body = body[: config.body_chars] + "\n\n_… truncated_"
|
|
|
|
return "\n\n".join(
|
|
part
|
|
for part in [
|
|
f"# [#{issue.number}]({issue.url}) {issue.title}",
|
|
f"**score {r.score:.1f}** · {kind} · {facts['age_days']:.0f}d old · {release_line} \n"
|
|
f"labels: {labels} · [open on GitHub]({issue.url})",
|
|
"## why\n\n" + ("\n".join(why_lines) or "_no strong signals_"),
|
|
f"## author\n\n**[{issue.author}](https://github.com/{issue.author})** · "
|
|
+ " · ".join(author_bits),
|
|
"## state\n\n" + " · ".join(state_bits),
|
|
judgment_line,
|
|
"---",
|
|
body,
|
|
]
|
|
if part
|
|
)
|
|
|
|
|
|
class RankApp(App[None]):
|
|
"""open issues, ranked by how much they deserve a maintainer's attention."""
|
|
|
|
TITLE = "issues"
|
|
|
|
CSS = f"""
|
|
Screen {{ background: {BG}; color: {INK}; }}
|
|
* {{
|
|
scrollbar-color: {BORDER};
|
|
scrollbar-color-hover: {BRIGHT};
|
|
scrollbar-color-active: {BRIGHT};
|
|
scrollbar-background: {PANEL};
|
|
scrollbar-corner-color: {PANEL};
|
|
scrollbar-size-vertical: 1;
|
|
}}
|
|
#hero {{
|
|
height: 3;
|
|
padding: 0 2;
|
|
background: {PANEL};
|
|
border-bottom: double {BORDER};
|
|
}}
|
|
#title {{ color: {BRIGHT}; text-style: bold; }}
|
|
#stats {{ color: {MUTED}; }}
|
|
#table {{ width: 3fr; height: 1fr; background: {BG}; }}
|
|
#detail {{
|
|
width: 2fr;
|
|
padding: 0 2;
|
|
border-left: heavy {BORDER};
|
|
background: {BG};
|
|
}}
|
|
DataTable > .datatable--header {{ background: {BG}; color: {BRIGHT}; text-style: bold; }}
|
|
DataTable > .datatable--cursor {{ background: {TRACK}; }}
|
|
DataTable > .datatable--even-row {{ background: {SCAN}; }}
|
|
DataTable > .datatable--odd-row {{ background: {BG}; }}
|
|
Markdown {{ background: {BG}; color: {INK}; }}
|
|
MarkdownH1 {{ color: {BRIGHT}; background: {BG}; text-style: bold; }}
|
|
MarkdownH2 {{ color: {BRIGHT}; text-style: bold; }}
|
|
MarkdownH3, MarkdownH4 {{ color: {BRIGHT}; }}
|
|
Footer {{ background: {PANEL}; }}
|
|
FooterKey {{ background: {PANEL}; color: {MUTED}; }}
|
|
FooterKey > .footer-key--key {{ background: {PANEL}; color: {BRIGHT}; }}
|
|
FooterKey > .footer-key--description {{ background: {PANEL}; color: {MUTED}; }}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("o", "open", "open"),
|
|
Binding("a", "open_author", "author"),
|
|
Binding("k", "cycle_kind", "kind"),
|
|
Binding("u", "toggle_unanswered", "unanswered"),
|
|
Binding("m", "more", "more"),
|
|
Binding("r", "refresh", "re-rank"),
|
|
Binding("q", "quit", "quit"),
|
|
]
|
|
|
|
def __init__(self, config: Config) -> None:
|
|
super().__init__()
|
|
self.config = config
|
|
self.ranked: list[Ranked] = []
|
|
self.shown: list[Ranked] = []
|
|
self.kind_filter: str | None = None
|
|
self.unanswered_only = False
|
|
self.progress: dict[str, tuple[int, int]] = {}
|
|
self.next_cursor: str | None = None
|
|
self.exhausted = False
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Vertical(id="hero"):
|
|
yield Static(f"issues · {self.config.repo}", id="title")
|
|
yield Static("ranking…", id="stats")
|
|
with Horizontal():
|
|
yield DataTable(id="table", cursor_type="row", zebra_stripes=True)
|
|
with VerticalScroll(id="detail"):
|
|
yield Markdown("_ranking…_", id="body")
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
table = self.query_one(DataTable)
|
|
table.add_columns("#", "issue", "score", "", "kind", "author", "age", "↩")
|
|
table.focus()
|
|
self.load()
|
|
|
|
@work(exclusive=True)
|
|
async def load(self, more: bool = False) -> None:
|
|
table = self.query_one(DataTable)
|
|
table.loading = True
|
|
self.progress = {}
|
|
try:
|
|
page = await rank(
|
|
self.config, self.report_progress, self.next_cursor if more else None
|
|
)
|
|
except Exception as exc:
|
|
self.notify(f"ranking failed: {exc}", severity="error", timeout=15)
|
|
return
|
|
finally:
|
|
table.loading = False
|
|
if more:
|
|
known = {r.issue.number for r in self.ranked}
|
|
merged = self.ranked + [
|
|
r for r in page.ranked if r.issue.number not in known
|
|
]
|
|
self.ranked = sorted(merged, key=lambda r: -r.score)
|
|
else:
|
|
self.ranked = page.ranked
|
|
self.next_cursor = page.next_cursor
|
|
self.exhausted = page.next_cursor is None
|
|
self.populate()
|
|
|
|
def report_progress(self, phase: str, done: int, total: int) -> None:
|
|
self.progress[phase] = (done, total)
|
|
self.query_one("#stats", Static).update(
|
|
"ranking… "
|
|
+ " · ".join(f"{name} {d}/{t}" for name, (d, t) in self.progress.items())
|
|
)
|
|
|
|
def populate(self) -> None:
|
|
self.shown = [
|
|
r
|
|
for r in self.ranked
|
|
if (self.kind_filter is None or top_kind(r)[0] == self.kind_filter)
|
|
and (not self.unanswered_only or r.facts.get("maintainer_replied") is False)
|
|
]
|
|
table = self.query_one(DataTable)
|
|
table.clear()
|
|
peak = max((r.score for r in self.ranked), default=0.0)
|
|
for position, r in enumerate(self.shown, 1):
|
|
kind, _ = top_kind(r)
|
|
table.add_row(
|
|
str(position),
|
|
f"#{r.issue.number} {r.issue.title[:40]}",
|
|
f"{r.score:.1f}",
|
|
score_bar(r.score, peak),
|
|
kind,
|
|
r.issue.author[:14],
|
|
f"{r.facts['age_days']:.0f}d",
|
|
"✓"
|
|
if r.facts.get("maintainer_replied")
|
|
else "·"
|
|
if r.facts.get("maintainer_replied") is False
|
|
else "?",
|
|
key=str(r.issue.number),
|
|
)
|
|
filters = [f"kind={self.kind_filter}"] if self.kind_filter else []
|
|
if self.unanswered_only:
|
|
filters.append("unanswered only")
|
|
unanswered = sum(
|
|
1 for r in self.ranked if r.facts.get("maintainer_replied") is False
|
|
)
|
|
security = sum(
|
|
1 for r in self.ranked if r.judgment and r.judgment.security >= 0.5
|
|
)
|
|
regressions = sum(
|
|
1 for r in self.ranked if r.components.get("regression", 0) >= 0.5
|
|
)
|
|
stats = (
|
|
f"{len(self.shown)} shown of {len(self.ranked)} loaded · {unanswered} without a maintainer reply · "
|
|
f"{security} security-flagged · {regressions} likely regressions · judge {self.config.judge}"
|
|
+ ("" if self.exhausted else " · m for more")
|
|
)
|
|
if filters:
|
|
stats += f" · {' · '.join(filters)}"
|
|
if self.config.judge == "none":
|
|
stats += " · no judge: facts and labels only (set TYPESAFE_API_KEY)"
|
|
self.query_one("#stats", Static).update(stats)
|
|
table.focus()
|
|
if self.shown:
|
|
self.show(self.shown[0])
|
|
else:
|
|
self.query_one(Markdown).update("_no issues match the filters_")
|
|
|
|
def show(self, r: Ranked) -> None:
|
|
self.query_one(Markdown).update(detail_markdown(r, self.config))
|
|
self.query_one("#detail", VerticalScroll).scroll_home(animate=False)
|
|
|
|
def selected(self) -> Ranked | None:
|
|
table = self.query_one(DataTable)
|
|
if not self.shown or table.cursor_row >= len(self.shown):
|
|
return None
|
|
return self.shown[table.cursor_row]
|
|
|
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
r = self.selected()
|
|
if r:
|
|
self.show(r)
|
|
|
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
self.action_open()
|
|
|
|
def action_open(self) -> None:
|
|
r = self.selected()
|
|
if r:
|
|
webbrowser.open(r.issue.url)
|
|
|
|
def action_open_author(self) -> None:
|
|
r = self.selected()
|
|
if r:
|
|
webbrowser.open(f"https://github.com/{r.issue.author}")
|
|
|
|
def action_cycle_kind(self) -> None:
|
|
index = KIND_FILTERS.index(self.kind_filter)
|
|
self.kind_filter = KIND_FILTERS[(index + 1) % len(KIND_FILTERS)]
|
|
self.populate()
|
|
|
|
def action_toggle_unanswered(self) -> None:
|
|
self.unanswered_only = not self.unanswered_only
|
|
self.populate()
|
|
|
|
def action_refresh(self) -> None:
|
|
self.next_cursor = None
|
|
self.load()
|
|
|
|
def action_more(self) -> None:
|
|
if self.exhausted:
|
|
self.notify("no more open issues")
|
|
return
|
|
self.load(more=True)
|
|
|
|
|
|
def main() -> None:
|
|
config, _ = parse_args()
|
|
RankApp(config).run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|