#!/usr/bin/env python3 """pragent pilot — read-mostly dashboard. Stdlib HTTP server (mirrors `webhook_server.py`'s BaseHTTPRequestHandler + ThreadingHTTPServer shape) that renders three views off the feedback SQLite: GET / overview GET /r// repo summary + edit form GET /r/// one PR's findings GET /r////raw raw Markdown body (via Gitea) GET /static/style.css CSS POST /r///edit mutate .pr-review.json (Tasks C+D) Auth: oauth2-proxy fronts this service in-cluster. Every route except `/static/*` requires the `X-Forwarded-User` header (set by oauth2-proxy once the user has logged in via Logto). Missing header → 401 + `WWW-Authenticate: Basic realm="pragent-dashboard"` so oauth2-proxy intercepts the response. DB: `PRAGENT_FEEDBACK_DB` points at the SQLite file the webhook server also writes. Per-request open (SQLite is cheap, no concurrency hazard, no stale-conn surprise after the file rotates). All HTML is rendered via `string.Template` and every dynamic value is escaped with `html.escape(..., quote=True)`. No `.format`, no f-string templates — see `_render_*` for the discipline. """ from __future__ import annotations import base64 import datetime import html import json import os import secrets import string import urllib.error import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pilot import dashboard_data # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- FEEDBACK_DB = "" # legacy; readers should call _feedback_db() PORT = int(os.environ.get("DASHBOARD_PORT", "8081")) GITEA_API = "" # legacy; readers should call _gitea_api() BOT_TOKEN = "" # legacy; readers should call _bot_token() # CSRF secret for the edit form. Regenerated per process (each Python # interpreter launch). Behind oauth2-proxy this is enough — only an # already-authenticated same-tab request can read this and echo it back. _CSRF_SECRET: str = secrets.token_urlsafe(24) # --------------------------------------------------------------------------- # Lazy config readers — tests set env after import, so each request re-reads. # Production: env is fixed for the process lifetime; the per-request lookup is # a dict access, not a syscall. # --------------------------------------------------------------------------- def _feedback_db() -> str: return os.environ.get("PRAGENT_FEEDBACK_DB", "") def _bot_token() -> str: return os.environ.get("PRAGENT_BOT_TOKEN", "") def _gitea_api() -> str: return os.environ.get("GITEA_API", "http://gitea-http.gitea.svc.cluster.local:3000") # --------------------------------------------------------------------------- # Stylesheet — small, dark-mode-friendly, deliberately under 100 lines # --------------------------------------------------------------------------- STYLE_CSS = """ :root { color-scheme: light dark; } * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; margin: 0; padding: 0; background: #0f1115; color: #e6e6e6; line-height: 1.5; } header { background: #1a1d23; padding: 12px 20px; border-bottom: 1px solid #2a2f38; display: flex; align-items: center; gap: 18px; } header h1 { font-size: 18px; margin: 0; } header nav a { color: #8ab4f8; text-decoration: none; margin-right: 12px; } header nav a:hover { text-decoration: underline; } main { padding: 20px; max-width: 1100px; margin: 0 auto; } h2 { margin-top: 24px; font-size: 16px; color: #c9d1d9; } .metric-row { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } .metric { background: #1a1d23; padding: 14px 18px; border-radius: 8px; min-width: 140px; border: 1px solid #2a2f38; } .metric .v { font-size: 28px; font-weight: 600; } .metric .l { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.04em; } table { width: 100%; border-collapse: collapse; margin: 8px 0 16px; font-size: 14px; } th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid #2a2f38; } th { color: #8b949e; font-weight: 500; text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; } tr:hover td { background: #161922; } .sev-critical { color: #ff7b72; font-weight: 600; } .sev-high { color: #f0883e; } .sev-medium { color: #d29922; } .sev-low { color: #8b949e; } .muted { color: #8b949e; font-size: 12px; } .sparkline { font-family: ui-monospace, "SF Mono", monospace; letter-spacing: 1px; } form { background: #1a1d23; padding: 14px 18px; border-radius: 8px; border: 1px solid #2a2f38; margin: 12px 0; } form label { display: block; margin: 8px 0 4px; color: #c9d1d9; font-size: 13px; } form input[type=text], form textarea, form select { background: #0f1115; color: #e6e6e6; border: 1px solid #2a2f38; border-radius: 4px; padding: 6px 8px; font-family: inherit; font-size: 14px; width: 100%; } form textarea { min-height: 80px; } form .row { display: flex; gap: 8px; align-items: center; margin-top: 12px; } form button { background: #2ea043; color: white; border: none; border-radius: 4px; padding: 6px 14px; font-size: 14px; cursor: pointer; } form button:hover { background: #3fb950; } .flash { background: #3d1e1e; color: #ff7b72; padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; } code { background: #161922; padding: 1px 4px; border-radius: 3px; font-size: 13px; } pre { background: #161922; padding: 12px; border-radius: 6px; overflow-x: auto; } """ # --------------------------------------------------------------------------- # Templates — string.Template so dynamic values are always escaped explicitly # --------------------------------------------------------------------------- _BASE = string.Template(""" ${title}

pragent dashboard

${db_status}
${body}
""") _OVERVIEW = string.Template("""

Overview

${total_reviews}
reviews
${total_findings}
findings
${total_repos}
repos
${last_30d_reviews}
last 30d

Last 7 days

${sparkline}
total cost: $${total_cost_usd} — no per-review cost logged

Top repos

${top_repos_table} """) _REPO = string.Template("""

Repo: ${repo}

${total_runs}
runs
${sev_critical}
critical
${sev_high}
high
${sev_medium}
medium
${sev_low}
low

Edit .pr-review.json

${flash}
posted via the bot identity; one commit on the base branch

Top findings (by occurrence)

${top_findings_table}

Runs by day (last 30d)

${runs_by_day_table}

Reviews

${reviews_table} """) _PR = string.Template("""

PR ${repo} #${pr}

head sha: ${head_sha}
posted_at: ${posted_at_iso}
review_id_gitea: ${review_id_gitea} · body_comment_id: ${body_comment_id}

Findings

${findings_table}

raw review body (Markdown)

""") # --------------------------------------------------------------------------- # Small helpers # --------------------------------------------------------------------------- def _esc(s) -> str: """HTML-escape any value to a string.""" return html.escape(str(s), quote=True) def _ts_iso(ts: int) -> str: if not ts: return "—" return datetime.datetime.fromtimestamp(int(ts), tz=datetime.timezone.utc).isoformat() def _sparkline(buckets: list[dict]) -> str: """7-bucket sparkline as unicode bars.""" bars = "▁▂▃▄▅▆▇█" if not buckets: return "" mx = max((b.get("count", 0) for b in buckets), default=0) or 1 out = [] for b in buckets: n = b.get("count", 0) idx = min(len(bars) - 1, int(round(n / mx * (len(bars) - 1)))) out.append(bars[idx]) return "".join(out) # --------------------------------------------------------------------------- # Renderers — one per page # --------------------------------------------------------------------------- def _overview_body(data: dict) -> str: top_rows = "".join( f"{_esc(r['repo'])}" f"{int(r['run_count'])}" f"{_ts_iso(int(r['last_seen']))}" for r in data.get("top_repos", []) ) or "no reviews yet" top_table = f"{top_rows}
reporunslast seen
" return _OVERVIEW.substitute( total_reviews=_esc(data.get("total_reviews", 0)), total_findings=_esc(data.get("total_findings", 0)), total_repos=_esc(data.get("total_repos", 0)), last_30d_reviews=_esc(data.get("last_30d_reviews", 0)), sparkline=_esc(_sparkline(data.get("daily", []))), total_cost_usd=f"{float(data.get('total_cost_usd', 0.0)):.2f}", top_repos_table=top_table, ) def _repo_body(data: dict, *, repo_url: str, csrf: str, current_model: str, current_static_message: str, flash: str = "") -> str: fbs = data.get("findings_by_severity", {}) tf = data.get("top_findings", []) # Top findings table. if tf: rows = "".join( f"{_esc(f['path'])}:{_esc(f['line'])}" f"{_esc(f.get('severity', ''))}" f"{_esc(f.get('problem', ''))}" f"{int(f.get('occurrences', 0))}" f"+{int(f.get('upvotes', 0))} / -{int(f.get('downvotes', 0))}" f"{'resolved' if int(f.get('resolved', 0)) else 'open'}" f"{int(f.get('reply_count', 0))}" for f in tf ) top_findings_table = ( "" "" "" f"{rows}
locationseverityproblemoccurrencesvotesstatereplies
" ) else: top_findings_table = "

no findings yet

" # Runs by day. runs = data.get("runs_by_day", []) if runs: rows = "".join( f"{_esc(r['date'])}{int(r.get('count', 0))}" for r in runs ) runs_by_day_table = ( "" f"{rows}
dateruns
" ) else: runs_by_day_table = "

no runs in the last 30 days

" # Reviews list — derived from finding timestamps; cheap because we # just enumerate the repo's review rows. reviews_table = _repo_reviews_table(repo_url, data.get("recent_reviews", [])) # Model select (Task D) — sorted PRICES keys + "keep current". from cost_model import PRICES # local: pilot-only dep model_options = ( f"" + "".join( f"" for k in sorted(PRICES) ) ) return _REPO.substitute( repo=_esc(data.get("repo", "")), repo_url=_esc(repo_url), total_runs=_esc(data.get("total_runs", 0)), sev_critical=_esc(fbs.get("critical", 0)), sev_high=_esc(fbs.get("high", 0)), sev_medium=_esc(fbs.get("medium", 0)), sev_low=_esc(fbs.get("low", 0)), csrf=_esc(csrf), current_static_message=_esc(current_static_message), model_options=model_options, flash=_esc(flash), top_findings_table=top_findings_table, runs_by_day_table=runs_by_day_table, reviews_table=reviews_table, ) def _repo_reviews_table(repo_url: str, rows: list[dict]) -> str: if not rows: return "

no reviews yet

" out = "" for r in rows: out += ( f"" f"" f"" ) out += "
PRhead shaposted
#{int(r['pr'])}{_esc(r['head_sha'][:10])}{_ts_iso(int(r.get('posted_at', 0)))}
" return out def _pr_body(data: dict, *, repo_url: str) -> str: findings = data.get("findings", []) if findings: rows = "".join( f"{_esc(f['path'])}:{_esc(f['line'])}" f"{_esc(f.get('severity', ''))}" f"{_esc(f.get('problem', ''))}" f"{_esc(f.get('fix', ''))}" f"{_esc(f.get('suggestion', ''))}" f"+{int(f.get('upvotes', 0))} / -{int(f.get('downvotes', 0))}" f"{'resolved' if int(f.get('resolved', 0)) else 'open'}" f"{int(f.get('reply_count', 0))}" for f in findings ) findings_table = ( "" "" "" f"{rows}
locationseverityproblemfixsuggestionvotesstatereplies
" ) else: findings_table = "

no findings

" return _PR.substitute( repo=_esc(data.get("repo", "")), repo_url=_esc(repo_url), pr=_esc(data.get("pr", 0)), head_sha=_esc(data.get("head_sha", "")), posted_at_iso=_ts_iso(int(data.get("posted_at", 0))), review_id_gitea=_esc(data.get("review_id_gitea", "") or "—"), body_comment_id=_esc(data.get("body_comment_id", "") or "—"), findings_table=findings_table, ) def _page(title: str, body: str, *, repos_first: str = "") -> str: db_status = _feedback_db() or "(no DB configured)" return _BASE.substitute( title=_esc(title), body=body, repos_first=_esc(repos_first), db_status=_esc(db_status), ) # --------------------------------------------------------------------------- # Gitea HTTP helper — minimal, used by the raw body fetch and the edit endpoint # --------------------------------------------------------------------------- def _http(method: str, url: str, *, token: str = "", body: dict | None = None, raw_body: bytes | None = None) -> tuple[int, bytes]: """Like ai_review._http but local: this module is stdlib-only and doesn't depend on the ai_review import (which pulls in a 1700-line reviewer).""" headers = {"Accept": "application/json"} data: bytes | None = None if raw_body is not None: data = raw_body headers["Content-Type"] = "application/json" elif body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" if token: headers["Authorization"] = f"token {token}" req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, r.read() except urllib.error.HTTPError as e: return e.code, e.read() except urllib.error.URLError as e: raise RuntimeError(f"network error: {e.reason}") from e # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- def _is_authed(headers) -> bool: """True when oauth2-proxy forwarded a verified user. oauth2-proxy sets `X-Forwarded-User` (and friends) only after a successful Logto login + email allowlist check. Unauthenticated requests never see the header, so the dashboard never has to know about cookies, secrets, or Logto's token shape. """ return bool((headers.get("X-Forwarded-User") or "").strip()) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- def _route_overview() -> bytes: data = dashboard_data.overview(_feedback_db()) body = _overview_body(data) # nav: first repo if any repos_first = "" if data.get("top_repos"): repos_first = data["top_repos"][0]["repo"] return _page("Overview", body, repos_first=repos_first).encode() def _route_repo(owner: str, name: str) -> bytes: repo_url = f"{owner}/{name}" data = dashboard_data.repo_summary(_feedback_db(), repo_url) # Pull current .pr-review.json (best-effort) so the form fields prefill. current_static_message, current_model, flash = "", "", "" cfg, err = _fetch_pr_review_json(repo_url) if cfg: current_static_message = cfg.get("static_message", "") current_model = cfg.get("model", "") elif err and err != "404": flash = f"could not read .pr-review.json: {err}" body = _repo_body( data, repo_url=repo_url, csrf=_CSRF_SECRET, current_model=current_model, current_static_message=current_static_message, flash=flash, ) return _page(f"repo {repo_url}", body, repos_first=repo_url).encode() def _route_pr(owner: str, name: str, index: int) -> bytes: repo_url = f"{owner}/{name}" data = dashboard_data.pr_summary(_feedback_db(), repo_url, int(index)) body = _pr_body(data, repo_url=repo_url) return _page(f"PR {repo_url}#{index}", body, repos_first=repo_url).encode() def _route_pr_raw(owner: str, name: str, index: int) -> tuple[int, bytes]: repo_url = f"{owner}/{name}" data = dashboard_data.pr_summary(_feedback_db(), repo_url, int(index)) body_comment_id = data.get("body_comment_id") if not body_comment_id: return 404, b"no body_comment_id" status, raw = _http( "GET", f"{_gitea_api()}/api/v1/repos/{repo_url}/issues/{index}/comments/{body_comment_id}", token=_bot_token(), ) if status != 200: return 404, f"Gitea returned {status}".encode() try: parsed = json.loads(raw) md = parsed.get("body", "") except (json.JSONDecodeError, ValueError): return 404, b"could not parse Gitea response" return 200, md.encode() def _route_static_css() -> bytes: return STYLE_CSS.encode() def _route_edit(owner: str, name: str, form: dict) -> tuple[int, dict, bytes]: """Mutate .pr-review.json via the Gitea contents API (Tasks C+D).""" repo_url = f"{owner}/{name}" csrf = form.get("_csrf", "") if csrf != _CSRF_SECRET: return 302, {"Location": f"/r/{repo_url}"}, b"" static_message = (form.get("static_message") or "").strip()[:400] model = (form.get("model") or "").strip() # Validate model against PRICES. from cost_model import PRICES if model and model not in PRICES: flash = urllib.parse.quote(f"unknown model {model!r}; not saved") return 302, {"Location": f"/r/{repo_url}?flash={flash}"}, b"" cfg, err = _fetch_pr_review_json(repo_url) if err and err != "404": flash = urllib.parse.quote(f"could not read .pr-review.json: {err}") return 302, {"Location": f"/r/{repo_url}?flash={flash}"}, b"" if cfg is None: cfg = {} if static_message: cfg["static_message"] = static_message elif "static_message" in cfg and not static_message: # Empty submission clears the banner. del cfg["static_message"] if model: cfg["model"] = model elif "model" in cfg and not model: del cfg["model"] payload = json.dumps(cfg, indent=2, sort_keys=True).encode() b64 = base64.b64encode(payload).decode() body = {"content": b64, "message": "pragent dashboard: update .pr-review.json"} if err == "404": # File didn't exist — Gitea contents PUT still creates the file when # `sha` is omitted, but only on certain versions; passing sha=None is # safer. pass else: # GET returned a sha — include it so Gitea enforces optimistic lock. # The sha lives in cfg's wrapper: re-fetch once to capture it. _, raw = _http( "GET", f"{_gitea_api()}/api/v1/repos/{repo_url}/contents/.pr-review.json", token=_bot_token(), ) try: existing = json.loads(raw) sha = existing.get("sha") if sha: body["sha"] = sha except (json.JSONDecodeError, ValueError): pass status, _ = _http( "PUT", f"{_gitea_api()}/api/v1/repos/{repo_url}/contents/.pr-review.json", token=_bot_token(), body=body, ) if status not in (200, 201): flash = urllib.parse.quote(f"Gitea PUT failed: status {status}") return 302, {"Location": f"/r/{repo_url}?flash={flash}"}, b"" return 302, {"Location": f"/r/{repo_url}"}, b"" def _fetch_pr_review_json(repo_url: str) -> tuple[dict | None, str | None]: """Return (cfg, None) on success, (None, None) when the file doesn't exist, (None, 'reason') on error.""" if not _bot_token(): return None, "PRAGENT_BOT_TOKEN not set" status, raw = _http( "GET", f"{_gitea_api()}/api/v1/repos/{repo_url}/contents/.pr-review.json", token=_bot_token(), ) if status == 404: return None, "404" if status != 200: return None, f"status {status}" try: wrapper = json.loads(raw) content_b64 = wrapper.get("content", "").replace("\n", "") decoded = base64.b64decode(content_b64).decode("utf-8", errors="replace") cfg = json.loads(decoded) except (json.JSONDecodeError, ValueError) as e: return None, f"parse error: {e}" if not isinstance(cfg, dict): return None, "not a JSON object" return cfg, None # --------------------------------------------------------------------------- # Handler # --------------------------------------------------------------------------- class Handler(BaseHTTPRequestHandler): def _send(self, status: int, body: bytes, *, content_type: str = "text/html; charset=utf-8", extra_headers: dict | None = None) -> None: self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) if extra_headers: for k, v in extra_headers.items(): self.send_header(k, v) self.end_headers() self.wfile.write(body) def _redirect(self, location: str) -> None: body = b"" self.send_response(302) self.send_header("Location", location) self.send_header("Content-Length", "0") self.end_headers() self.wfile.write(body) def _unauthorized(self) -> None: """401 + Basic challenge so oauth2-proxy intercepts and redirects to Logto.""" body = b"unauthorized\n" self.send_response(401) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("WWW-Authenticate", 'Basic realm="pragent-dashboard"') self.end_headers() self.wfile.write(body) # --- GET ----------------------------------------------------------------- def do_GET(self): path = self.path # Static is exempt from auth (also unauthenticated browser fingerprinting # noise, but it's the same CSS regardless of viewer). if path == "/static/style.css": self._send(200, _route_static_css(), content_type="text/css; charset=utf-8") return if not _is_authed(self.headers): self._unauthorized() return if path == "/" or path == "": self._send(200, _route_overview()) return # /r// → repo # /r/// → PR # /r////raw → raw Markdown m = _REPO_PR_RAW_RE.match(path) if m: owner, name, idx, raw = m.group(1), m.group(2), m.group(3), m.group(4) if raw: status, body = _route_pr_raw(owner, name, int(idx)) self._send(status, body, content_type="text/plain; charset=utf-8" if status == 200 else "text/plain") return if idx: self._send(200, _route_pr(owner, name, int(idx))) return self._send(200, _route_repo(owner, name)) return self._send(404, b"not found", content_type="text/plain") # --- POST ---------------------------------------------------------------- def do_POST(self): path = self.path if not _is_authed(self.headers): self._unauthorized() return # /r///edit m = _EDIT_RE.match(path) if m: owner, name = m.group(1), m.group(2) length = int(self.headers.get("Content-Length", "0") or "0") raw = self.rfile.read(length) if length else b"" form = urllib.parse.parse_qs(raw.decode("utf-8", errors="replace")) # Collapse lists to single values. form_single = {k: v[0] for k, v in form.items()} status, extra, body = _route_edit(owner, name, form_single) self._send(status, body, content_type="text/plain", extra_headers=extra) return self._send(404, b"not found", content_type="text/plain") def log_message(self, fmt, *args): print(f"pragent-dashboard: {self.address_string()} {fmt % args}", flush=True) # --------------------------------------------------------------------------- # Routing regexes (compiled at import time) # --------------------------------------------------------------------------- import re # noqa: E402 _REPO_PR_RAW_RE = re.compile( r"^/r/([^/]+)/([^/]+)(?:/(\d+)(?:/(raw))?)?/?$" ) _EDIT_RE = re.compile(r"^/r/([^/]+)/([^/]+)/edit/?$") # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> int: if not _feedback_db(): print("pragent-dashboard: WARNING: PRAGENT_FEEDBACK_DB not set; dashboard will be empty", flush=True) print("pragent-dashboard: auth via oauth2-proxy (X-Forwarded-User required)", flush=True) server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) print(f"pragent-dashboard: listening on :{PORT}", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass return 0 if __name__ == "__main__": raise SystemExit(main())