feat: gadfly-reports — findings store + scoreboard daemon
SQLite-backed HTTP store for Gadfly review findings, per-review run timings, and human/Claude grades, with a points-free per-model scoreboard. Pure fact store: it computes no points or rankings (the dashboard maps severity->points client-side and retunes without re-scoring). Findings are content-addressed by location so cross-model reports collapse for consensus; one grade per finding, latest wins. Pure-Go SQLite (CGO-free) + Docker image CI + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// newServer wires the store to the HTTP API. If token is non-empty, every route
|
||||
// except /healthz requires "Authorization: Bearer <token>".
|
||||
//
|
||||
// Routes:
|
||||
//
|
||||
// GET /healthz liveness
|
||||
// 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, …)
|
||||
// GET /export flat report×finding×grade rows (the dashboard feed)
|
||||
// GET /scoreboard points-free per-model rollup
|
||||
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, _ *http.Request) {
|
||||
stats, err := store.Scoreboard()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
})
|
||||
|
||||
return auth(token, mux)
|
||||
}
|
||||
|
||||
// auth gates everything but /healthz 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 != "" && r.URL.Path != "/healthz" {
|
||||
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()})
|
||||
}
|
||||
Reference in New Issue
Block a user