Files
pragent/pilot/dashboard.py
T
Claude be24ef245c feat(dashboard): stdlib HTTP server with overview/repo/PR pages
Mirrors webhook_server.py's BaseHTTPRequestHandler + ThreadingHTTPServer
shape. Pure stdlib, no pip deps. Routes:

  GET  /                            overview (totals + 7-day sparkline + top repos)
  GET  /r/<owner>/<name>            repo summary (severity histogram + top findings)
  GET  /r/<owner>/<name>/<index>    one PR's findings
  GET  /r/<owner>/<name>/<index>/raw raw Markdown body (via Gitea contents API)
  GET  /static/style.css            dark-mode stylesheet
  GET  /login                       login form
  POST /login                       compare token, set HttpOnly+SameSite cookie
  POST /r/<owner>/<name>/edit       (Tasks C+D)

Auth: when PRAGENT_DASHBOARD_TOKEN is set, every route except /login and
/static/* requires Cookie: pragent_dash=<token>. Unset → tailnet-only.

All HTML rendered via string.Template; every dynamic value is escaped
with html.escape(..., quote=True). No .format, no f-string templates.

12 tests under tests/pilot/test_dashboard.py.
2026-08-22 15:11:36 +00:00

791 lines
29 KiB
Python

#!/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/<owner>/<name> repo summary + edit form
GET /r/<owner>/<name>/<index> one PR's findings
GET /r/<owner>/<name>/<index>/raw raw Markdown body (via Gitea)
GET /static/style.css CSS
GET /login login form
POST /login check token, set cookie
POST /r/<owner>/<name>/edit mutate .pr-review.json (Tasks C+D)
Auth: when `PRAGENT_DASHBOARD_TOKEN` is set, every route except `/login`,
`/static/*`, and `POST /login` requires `Cookie: pragent_dash=<token>`.
Unset → tailnet-only mode, no auth.
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 string
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pilot import dashboard_data
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
FEEDBACK_DB = "" # legacy; readers should call _feedback_db()
DASHBOARD_TOKEN = "" # legacy; readers should call _dashboard_token()
PORT = int(os.environ.get("DASHBOARD_PORT", "8081"))
GITEA_API = "" # legacy; readers should call _gitea_api()
BOT_TOKEN = "" # legacy; readers should call _bot_token()
COOKIE_NAME = "pragent_dash"
# ---------------------------------------------------------------------------
# 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 _dashboard_token() -> str:
return os.environ.get("PRAGENT_DASHBOARD_TOKEN", "")
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("""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${title}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>pragent dashboard</h1>
<nav>
<a href="/">Home</a>
<a href="/r/${repos_first}">repos</a>
</nav>
<span class="muted" style="margin-left:auto">${db_status}</span>
</header>
<main>
${body}
</main>
</body>
</html>""")
_OVERVIEW = string.Template("""<h2>Overview</h2>
<div class="metric-row">
<div class="metric"><div class="v">${total_reviews}</div><div class="l">reviews</div></div>
<div class="metric"><div class="v">${total_findings}</div><div class="l">findings</div></div>
<div class="metric"><div class="v">${total_repos}</div><div class="l">repos</div></div>
<div class="metric"><div class="v">${last_30d_reviews}</div><div class="l">last 30d</div></div>
</div>
<h2>Last 7 days</h2>
<div class="sparkline">${sparkline}</div>
<div class="muted">total cost: $${total_cost_usd} — no per-review cost logged</div>
<h2>Top repos</h2>
${top_repos_table}
""")
_REPO = string.Template("""<h2>Repo: <code>${repo}</code></h2>
<div class="metric-row">
<div class="metric"><div class="v">${total_runs}</div><div class="l">runs</div></div>
<div class="metric"><div class="v">${sev_critical}</div><div class="l sev-critical">critical</div></div>
<div class="metric"><div class="v">${sev_high}</div><div class="l sev-high">high</div></div>
<div class="metric"><div class="v">${sev_medium}</div><div class="l sev-medium">medium</div></div>
<div class="metric"><div class="v">${sev_low}</div><div class="l sev-low">low</div></div>
</div>
<h2>Edit .pr-review.json</h2>
${flash}
<form method="post" action="/r/${repo_url}/edit">
<input type="hidden" name="_csrf" value="${csrf}">
<label for="static_message">Static banner message (max 400 chars)</label>
<textarea id="static_message" name="static_message" maxlength="400">${current_static_message}</textarea>
<label for="model">Model (PRICES keys)</label>
<select id="model" name="model">${model_options}</select>
<div class="row">
<button type="submit">Save</button>
<span class="muted">posted via the bot identity; one commit on the base branch</span>
</div>
</form>
<h2>Top findings (by occurrence)</h2>
${top_findings_table}
<h2>Runs by day (last 30d)</h2>
${runs_by_day_table}
<h2>Reviews</h2>
${reviews_table}
""")
_PR = string.Template("""<h2>PR <code>${repo}</code> #${pr}</h2>
<div class="muted">head sha: <code>${head_sha}</code></div>
<div class="muted">posted_at: ${posted_at_iso}</div>
<div class="muted">review_id_gitea: ${review_id_gitea} · body_comment_id: ${body_comment_id}</div>
<h2>Findings</h2>
${findings_table}
<p><a href="/r/${repo_url}/${pr}/raw">raw review body (Markdown)</a></p>
""")
_LOGIN = string.Template("""<h2>Login</h2>
${flash}
<form method="post" action="/login">
<label for="token">Dashboard token</label>
<input type="text" id="token" name="token" autofocus>
<div class="row">
<button type="submit">Sign in</button>
</div>
</form>
""")
# ---------------------------------------------------------------------------
# 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"<tr><td><a href=\"/r/{_esc(r['repo'])}\">{_esc(r['repo'])}</a></td>"
f"<td>{int(r['run_count'])}</td>"
f"<td class=\"muted\">{_ts_iso(int(r['last_seen']))}</td></tr>"
for r in data.get("top_repos", [])
) or "<tr><td class=\"muted\">no reviews yet</td></tr>"
top_table = f"<table><thead><tr><th>repo</th><th>runs</th><th>last seen</th></tr></thead><tbody>{top_rows}</tbody></table>"
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"<tr><td><code>{_esc(f['path'])}:{_esc(f['line'])}</code></td>"
f"<td class=\"sev-{_esc(f.get('severity', 'low').lower())}\">{_esc(f.get('severity', ''))}</td>"
f"<td>{_esc(f.get('problem', ''))}</td>"
f"<td>{int(f.get('occurrences', 0))}</td>"
f"<td>+{int(f.get('upvotes', 0))} / -{int(f.get('downvotes', 0))}</td>"
f"<td>{'resolved' if int(f.get('resolved', 0)) else 'open'}</td>"
f"<td>{int(f.get('reply_count', 0))}</td></tr>"
for f in tf
)
top_findings_table = (
"<table><thead><tr><th>location</th><th>severity</th>"
"<th>problem</th><th>occurrences</th><th>votes</th>"
"<th>state</th><th>replies</th></tr></thead><tbody>"
f"{rows}</tbody></table>"
)
else:
top_findings_table = "<p class=\"muted\">no findings yet</p>"
# Runs by day.
runs = data.get("runs_by_day", [])
if runs:
rows = "".join(
f"<tr><td>{_esc(r['date'])}</td><td>{int(r.get('count', 0))}</td></tr>"
for r in runs
)
runs_by_day_table = (
"<table><thead><tr><th>date</th><th>runs</th></tr></thead>"
f"<tbody>{rows}</tbody></table>"
)
else:
runs_by_day_table = "<p class=\"muted\">no runs in the last 30 days</p>"
# 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"<option value=\"\">— keep current ({_esc(current_model or 'unset')}) —</option>"
+ "".join(
f"<option value=\"{_esc(k)}\" {'selected' if k == current_model else ''}>{_esc(k)}</option>"
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 "<p class=\"muted\">no reviews yet</p>"
out = "<table><thead><tr><th>PR</th><th>head sha</th><th>posted</th></tr></thead><tbody>"
for r in rows:
out += (
f"<tr><td><a href=\"/r/{_esc(repo_url)}/{int(r['pr'])}\">#{int(r['pr'])}</a></td>"
f"<td><code>{_esc(r['head_sha'][:10])}</code></td>"
f"<td class=\"muted\">{_ts_iso(int(r.get('posted_at', 0)))}</td></tr>"
)
out += "</tbody></table>"
return out
def _pr_body(data: dict, *, repo_url: str) -> str:
findings = data.get("findings", [])
if findings:
rows = "".join(
f"<tr><td><code>{_esc(f['path'])}:{_esc(f['line'])}</code></td>"
f"<td class=\"sev-{_esc(f.get('severity', 'low').lower())}\">{_esc(f.get('severity', ''))}</td>"
f"<td>{_esc(f.get('problem', ''))}</td>"
f"<td>{_esc(f.get('fix', ''))}</td>"
f"<td>{_esc(f.get('suggestion', ''))}</td>"
f"<td>+{int(f.get('upvotes', 0))} / -{int(f.get('downvotes', 0))}</td>"
f"<td>{'resolved' if int(f.get('resolved', 0)) else 'open'}</td>"
f"<td>{int(f.get('reply_count', 0))}</td></tr>"
for f in findings
)
findings_table = (
"<table><thead><tr><th>location</th><th>severity</th>"
"<th>problem</th><th>fix</th><th>suggestion</th>"
"<th>votes</th><th>state</th><th>replies</th></tr></thead>"
f"<tbody>{rows}</tbody></table>"
)
else:
findings_table = "<p class=\"muted\">no findings</p>"
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 _login_body(flash: str = "") -> str:
return _LOGIN.substitute(flash=_esc(flash))
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)."""
import urllib.request
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 token matches; false when token unset OR cookie absent/wrong."""
tok = _dashboard_token()
if not tok:
return True # no token → tailnet-only mode
cookie = headers.get("Cookie") or ""
for part in cookie.split(";"):
k, _, v = part.strip().partition("=")
if k == COOKIE_NAME and v == tok:
return True
return False
# ---------------------------------------------------------------------------
# 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}"
csrf = _dashboard_token() or "noauth"
body = _repo_body(
data,
repo_url=repo_url,
csrf=csrf,
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_login_get() -> bytes:
return _page("login", _login_body()).encode()
def _route_login_post(token: str) -> tuple[int, dict, bytes]:
"""Compare token to env. Sets cookie on match, 401 otherwise."""
expected = _dashboard_token()
if not expected or token != expected:
return 401, {}, b"invalid token"
cookie = f"{COOKIE_NAME}={expected}; HttpOnly; SameSite=Strict; Path=/"
return 302, {"Set-Cookie": cookie, "Location": "/"}, b""
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", "")
expected_csrf = _dashboard_token() or "noauth"
if csrf != expected_csrf:
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)
# --- GET -----------------------------------------------------------------
def do_GET(self):
path = self.path
# Static is exempt from auth so the login page can load its CSS.
if path == "/static/style.css":
self._send(200, _route_static_css(), content_type="text/css; charset=utf-8")
return
if path == "/login":
self._send(200, _route_login_get())
return
if not _is_authed(self.headers):
self._redirect("/login")
return
if path == "/" or path == "":
self._send(200, _route_overview())
return
# /r/<owner>/<name> → repo
# /r/<owner>/<name>/<index> → PR
# /r/<owner>/<name>/<index>/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
# Login POST is exempt from auth (obviously).
if path == "/login":
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"))
token = (form.get("token") or [""])[0]
status, extra, body = _route_login_post(token)
self._send(status, body, content_type="text/plain", extra_headers=extra)
return
if not _is_authed(self.headers):
self._redirect("/login")
return
# /r/<owner>/<name>/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)
if _dashboard_token():
print("pragent-dashboard: auth ON (PRAGENT_DASHBOARD_TOKEN set)", flush=True)
else:
print("pragent-dashboard: auth OFF (tailnet-only mode)", 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())