feat(dashboard): read-only query module over feedback SQLite

Three pure functions — overview / repo_summary / pr_summary — that open
the feedback SQLite via feedback.init, run their queries, and return
plain dicts/lists. All three tolerate a missing or empty DB by returning
a zero-shaped dict.

Cost is hardcoded 0.0: per-review usage:cost isn't stored, only the raw
review rows are. Surfacing a rolled-up dollar figure without telemetry
would be guessing, so we don't.

17 new tests under tests/pilot/test_dashboard_data.py.
This commit is contained in:
Claude
2026-08-22 15:07:57 +00:00
parent fe5bebb4cf
commit bb9b6aa12d
2 changed files with 551 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
"""pragent pilot — dashboard read-only query layer.
Three functions: overview / repo_summary / pr_summary. Each opens the SQLite
feedback DB via `feedback.init`, runs the queries it needs, and returns plain
dicts/lists. NEVER writes — that's the dashboard_server's job (via the Gitea
contents API). This module is what the dashboard_server's templates render.
All three functions are tolerant of a missing or empty DB: they return the
shaped dict with zeros/empty lists rather than crashing. The dashboard is a
read-only view; the pilot can boot with no feedback DB and the dashboard
should still load.
Cost note: `total_cost_usd` is hardcoded to 0.0. Per-review `usage:cost` is
not in the feedback SQLite — only the raw `review` / `inline_finding` rows
are stored there. The equivalent-cost calc lives in `ai_review._render_collapsible_usage`
and only knows about the latest review's tokens. Surfacing a rolled-up dollar
figure without per-row telemetry would be guessing, so we don't.
"""
from __future__ import annotations
import datetime
import os
import sqlite3
from pilot import feedback
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _empty_overview() -> dict:
return {
"total_reviews": 0,
"total_findings": 0,
"total_repos": 0,
"last_30d_reviews": 0,
"daily": [{"date": _iso_date(i), "count": 0} for i in range(7)],
"top_repos": [],
"total_cost_usd": 0.0,
}
def _empty_repo_summary(repo: str) -> dict:
return {
"repo": repo,
"total_runs": 0,
"last_run_ts": 0,
"runs_by_day": [],
"findings_by_severity": {"critical": 0, "high": 0, "medium": 0, "low": 0},
"top_findings": [],
# NOTE: review rows don't carry a `model` column in the schema today,
# so we have nothing to aggregate. When that lands, replace this
# empty list with a `SELECT model, COUNT(*) …` over `review`.
"models_used": [],
}
def _empty_pr_summary(repo: str, pr: int) -> dict:
return {
"repo": repo,
"pr": pr,
"head_sha": "",
"posted_at": 0,
"review_id_gitea": None,
"body_comment_id": None,
"findings": [],
# usage isn't on the review row today; ai_review.py renders it
# in-memory at review time. Leave empty.
"usage": {},
}
def _iso_date(days_ago: int) -> str:
"""Return YYYY-MM-DD for `days_ago` days before today (UTC)."""
d = datetime.datetime.now(datetime.timezone.utc).date() - datetime.timedelta(days=days_ago)
return d.isoformat()
def _open_or_none(db_path: str) -> sqlite3.Connection | None:
"""Open the DB if it exists and looks like a feedback DB. Else None.
Tolerates missing files (fresh container) and a schema-less file (the
operator dropped a stray DB at the path). Returns a connection with
Row factory set so callers can use `row["col"]`.
"""
if not db_path or not os.path.exists(db_path):
return None
try:
conn = feedback.init(db_path)
except sqlite3.DatabaseError:
return None
return conn
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def overview(db_path: str) -> dict:
"""Top-of-page summary: totals + 7-bucket daily sparkline + top 5 repos."""
conn = _open_or_none(db_path)
if conn is None:
return _empty_overview()
try:
cur = conn.execute("SELECT COUNT(*) FROM review")
total_reviews = cur.fetchone()[0]
cur = conn.execute("SELECT COUNT(*) FROM inline_finding")
total_findings = cur.fetchone()[0]
cur = conn.execute("SELECT COUNT(DISTINCT repo) FROM review")
total_repos = cur.fetchone()[0]
# Last 30d window — reviews AND findings posted within the window.
ts_30d_ago = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) - 30 * 86400
cur = conn.execute("SELECT COUNT(*) FROM review WHERE posted_at >= ?", (ts_30d_ago,))
last_30d_reviews = cur.fetchone()[0]
# 7-bucket daily sparkline, oldest first. Bucket key is UTC date.
cur = conn.execute(
"SELECT posted_at FROM review WHERE posted_at >= ?",
(int(datetime.datetime.now(datetime.timezone.utc).timestamp()) - 7 * 86400,),
)
buckets: dict[str, int] = {_iso_date(i): 0 for i in range(7)}
for (ts,) in cur.fetchall():
d = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).date().isoformat()
if d in buckets:
buckets[d] += 1
daily = [{"date": _iso_date(i), "count": buckets[_iso_date(i)]} for i in range(7)]
# Top 5 repos by run count, descending. last_seen is the most recent
# review timestamp on that repo.
cur = conn.execute(
"SELECT repo, COUNT(*) AS runs, MAX(posted_at) AS last_seen "
"FROM review GROUP BY repo ORDER BY runs DESC, last_seen DESC LIMIT 5"
)
top_repos = [
{"repo": row[0], "run_count": row[1], "last_seen": int(row[2])}
for row in cur.fetchall()
]
return {
"total_reviews": total_reviews,
"total_findings": total_findings,
"total_repos": total_repos,
"last_30d_reviews": last_30d_reviews,
"daily": daily,
"top_repos": top_repos,
"total_cost_usd": 0.0,
}
finally:
conn.close()
def repo_summary(db_path: str, repo: str) -> dict:
"""Per-repo drill-down: runs by day, severity histogram, top findings."""
conn = _open_or_none(db_path)
if conn is None:
return _empty_repo_summary(repo)
try:
cur = conn.execute(
"SELECT COUNT(*), MAX(posted_at) FROM review WHERE repo = ?", (repo,)
)
row = cur.fetchone()
total_runs = row[0] or 0
last_run_ts = int(row[1]) if row[1] else 0
# runs_by_day for the last 30 days, oldest first; zero-buckets included.
cur = conn.execute(
"SELECT posted_at FROM review WHERE repo = ? AND posted_at >= ?",
(repo, int(datetime.datetime.now(datetime.timezone.utc).timestamp()) - 30 * 86400),
)
buckets: dict[str, int] = {}
for d in range(30):
buckets[_iso_date(d)] = 0 # newest-day mapped to 0; we'll iterate
# Re-key: build oldest-first, days_ago goes 29..0
oldest_first = {}
for d in range(30):
oldest_first[_iso_date(29 - d)] = 0
for (ts,) in cur.fetchall():
d = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).date().isoformat()
if d in oldest_first:
oldest_first[d] += 1
runs_by_day = [{"date": k, "count": v} for k, v in oldest_first.items()]
# findings_by_severity — case-insensitive match; bucket unknown as 'low'.
cur = conn.execute(
"SELECT severity, COUNT(*) FROM inline_finding WHERE repo = ? GROUP BY severity",
(repo,),
)
fbs = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for sev, n in cur.fetchall():
k = (sev or "").strip().lower()
if k not in fbs:
k = "low"
fbs[k] += n
# top_findings — top 5 posthashes by occurrence count, joined with
# vote rollups via feedback.findings_with_votes.
cur = conn.execute(
"SELECT f.path, f.line, MAX(f.severity) AS severity, MAX(f.problem) AS problem, "
"COUNT(*) AS occurrences, "
"COALESCE(SUM(CASE WHEN rct.content = '+1' THEN 1 ELSE 0 END), 0) AS upvotes, "
"COALESCE(SUM(CASE WHEN rct.content = '-1' THEN 1 ELSE 0 END), 0) AS downvotes, "
"MAX(ts.resolved) AS resolved, "
"COALESCE((SELECT COUNT(*) FROM reply WHERE finding_id IN "
" (SELECT id FROM inline_finding WHERE posthash = f.posthash AND repo = f.repo AND path = f.path AND line = f.line)), 0) AS reply_count "
"FROM inline_finding f "
"LEFT JOIN reaction rct ON rct.comment_id = f.comment_id "
"LEFT JOIN thread_state ts ON ts.finding_id = f.id "
"WHERE f.repo = ? "
"GROUP BY f.posthash, f.repo, f.path, f.line "
"ORDER BY occurrences DESC, upvotes DESC LIMIT 5",
(repo,),
)
top_findings = [
{
"path": r[0],
"line": r[1],
"severity": r[2],
"problem": r[3],
"occurrences": r[4],
"upvotes": int(r[5] or 0),
"downvotes": int(r[6] or 0),
"resolved": int(r[7] or 0),
"reply_count": int(r[8] or 0),
}
for r in cur.fetchall()
]
return {
"repo": repo,
"total_runs": total_runs,
"last_run_ts": last_run_ts,
"runs_by_day": runs_by_day,
"findings_by_severity": fbs,
"top_findings": top_findings,
"models_used": [], # see _empty_repo_summary NOTE
}
finally:
conn.close()
def pr_summary(db_path: str, repo: str, pr: int) -> dict:
"""Per-PR view: meta + every finding the bot ever posted on that PR."""
conn = _open_or_none(db_path)
if conn is None:
return _empty_pr_summary(repo, pr)
try:
cur = conn.execute(
"SELECT head_sha, posted_at, review_id_gitea, body_comment_id "
"FROM review WHERE repo = ? AND pr = ? ORDER BY posted_at DESC LIMIT 1",
(repo, pr),
)
row = cur.fetchone()
if row is None:
return _empty_pr_summary(repo, pr)
head_sha, posted_at, review_id_gitea, body_comment_id = row
cur = conn.execute(
"SELECT f.path, f.line, f.severity, f.problem, f.fix, f.suggestion, "
"COALESCE(SUM(CASE WHEN rct.content = '+1' THEN 1 ELSE 0 END), 0) AS upvotes, "
"COALESCE(SUM(CASE WHEN rct.content = '-1' THEN 1 ELSE 0 END), 0) AS downvotes, "
"MAX(ts.resolved) AS resolved, "
"COALESCE((SELECT COUNT(*) FROM reply WHERE finding_id = f.id), 0) AS reply_count "
"FROM inline_finding f "
"LEFT JOIN reaction rct ON rct.comment_id = f.comment_id "
"LEFT JOIN thread_state ts ON ts.finding_id = f.id "
"WHERE f.repo = ? AND f.pr = ? "
"GROUP BY f.id "
"ORDER BY f.path, f.line",
(repo, pr),
)
findings = [
{
"path": r[0],
"line": r[1],
"severity": r[2],
"problem": r[3],
"fix": r[4],
"suggestion": r[5],
"upvotes": int(r[6] or 0),
"downvotes": int(r[7] or 0),
"resolved": int(r[8] or 0),
"reply_count": int(r[9] or 0),
}
for r in cur.fetchall()
]
return {
"repo": repo,
"pr": pr,
"head_sha": head_sha,
"posted_at": int(posted_at),
"review_id_gitea": review_id_gitea,
"body_comment_id": body_comment_id,
"findings": findings,
"usage": {},
}
finally:
conn.close()