1af115fdf1
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>
173 lines
5.6 KiB
Go
173 lines
5.6 KiB
Go
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()})
|
||
}
|