Files
gadfly-reports/server.go
T
steve 1af115fdf1
Build & push image / build-and-push (push) Successful in 13s
CI / test (push) Successful in 9m51s
feat: PR filter — compare models on the same set of PRs
UI: a repo#pr multi-select (labeled with how many models ran each PR)
scopes the whole table — runs, minutes, findings, points — to the chosen
PRs, so a model with 2 runs can be fairly compared against one with 60.
API: GET /scoreboard accepts ?repo= and ?pr= (repeatable or comma-list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:56:49 -04:00

173 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"strings"
)
// newServer wires the store to the HTTP API. If token is non-empty, every route
// requires "Authorization: Bearer <token>" EXCEPT the always-public ones:
// /healthz and the view shell (/ and /ui). The dashboard's data still comes from
// the token-gated endpoints — its JS sends the token — so the public shell leaks
// no data on its own.
//
// Routes:
//
// GET / redirect to /ui (public)
// GET /ui read-only dashboard (HTML shell; data via fetch) (public)
// GET /healthz liveness (public)
// GET /runs list all runs (timing/tokens), oldest first
// GET /export flat report×finding×grade rows (the dashboard feed)
// GET /scoreboard points-free per-model rollup; ?repo= and ?pr= (repeatable
// or comma-list) narrow it to specific PRs so models are
// compared on the same work
// POST /runs upsert one run (model review of a PR; timing/tokens)
// POST /reports record a batch of findings + this model's reports
// POST /findings/{id}/grade record a triage grade (is_real, severity, …)
func newServer(store *Store, token string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("POST /runs", func(w http.ResponseWriter, r *http.Request) {
var run Run
if !decode(w, r, &run) {
return
}
if err := store.AddRun(run); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"run_id": run.RunID})
})
mux.HandleFunc("POST /reports", func(w http.ResponseWriter, r *http.Request) {
var reps []ReportIn
if !decode(w, r, &reps) {
return
}
ids, err := store.AddReports(reps)
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"finding_ids": ids})
})
mux.HandleFunc("POST /findings/{id}/grade", func(w http.ResponseWriter, r *http.Request) {
var g Grade
if !decode(w, r, &g) {
return
}
g.FindingID = r.PathValue("id")
if err := store.AddGrade(g); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"finding_id": g.FindingID})
})
mux.HandleFunc("GET /export", func(w http.ResponseWriter, _ *http.Request) {
rows, err := store.Export()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, rows)
})
mux.HandleFunc("GET /scoreboard", func(w http.ResponseWriter, r *http.Request) {
f := ScoreboardFilter{Repo: r.URL.Query().Get("repo")}
// pr is repeatable and accepts comma lists: ?pr=1&pr=2 or ?pr=1,2
for _, v := range r.URL.Query()["pr"] {
for part := range strings.SplitSeq(v, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err != nil {
writeErr(w, http.StatusBadRequest, errors.New("invalid pr number: "+part))
return
}
f.PRs = append(f.PRs, n)
}
}
stats, err := store.Scoreboard(f)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, stats)
})
mux.HandleFunc("GET /runs", func(w http.ResponseWriter, _ *http.Request) {
runs, err := store.ListRuns()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, runs)
})
// View-only dashboard. The shell is public (no data); it fetches the
// token-gated endpoints from the browser with the token the user supplies.
mux.HandleFunc("GET /ui", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(uiHTML))
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui", http.StatusFound)
})
return auth(token, mux)
}
// publicPaths are reachable without the bearer token even when one is set: the
// liveness probe and the dashboard SHELL (which carries no data — its JS fetches
// the gated endpoints with the token).
func isPublicPath(p string) bool {
return p == "/healthz" || p == "/" || p == "/ui" || strings.HasPrefix(p, "/ui/")
}
// auth gates every non-public route behind a bearer token, when one is set.
func auth(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token != "" && !isPublicPath(r.URL.Path) {
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if strings.TrimSpace(got) != token {
writeErr(w, http.StatusUnauthorized, errors.New("missing or invalid bearer token"))
return
}
}
next.ServeHTTP(w, r)
})
}
// decode reads a JSON body into v, writing a 400 and returning false on failure.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeErr(w, http.StatusBadRequest, errors.New("invalid JSON body: "+err.Error()))
return false
}
return true
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("gadfly-reports: write response: %v", err)
}
}
func writeErr(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()})
}