feat(hyperguild): brain pass-rate subcommand

Adds 'hyperguild brain pass-rate <skill> [--window 7d] [--json]'
calling the new /pass-rate endpoint. Human output:
  tdd: 47 / 50 = 94% (window: 7d)
or 'no data (window: 7d)' when pass_rate is null.

PassRateResult mirrors the response envelope; PassRate is *float64
so null is preserved across decode.
This commit is contained in:
Mathias Bergqvist
2026-05-03 22:44:04 +02:00
parent 417bf224eb
commit 593d1a4c6d
4 changed files with 171 additions and 2 deletions

View File

@@ -11,15 +11,17 @@ import (
func runBrain(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
if len(args) == 0 {
return errors.New("subcommand required (query|write)")
return errors.New("subcommand required (query|write|pass-rate)")
}
switch args[0] {
case "query":
return runBrainQuery(ctx, args[1:], stdin, stdout, stderr)
case "write":
return runBrainWrite(ctx, args[1:], stdin, stdout, stderr)
case "pass-rate":
return runBrainPassRate(ctx, args[1:], stdin, stdout, stderr)
default:
return fmt.Errorf("unknown subcommand: %s (expected query|write)", args[0])
return fmt.Errorf("unknown subcommand: %s (expected query|write|pass-rate)", args[0])
}
}
@@ -71,3 +73,34 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout,
fmt.Fprintln(stdout, res.Path) //nolint:errcheck
return nil
}
func runBrainPassRate(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("brain pass-rate", flag.ContinueOnError)
fs.SetOutput(stderr)
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
window := fs.String("window", "7d", "lookback window (e.g. 1h, 24h, 7d, 30d)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if fs.NArg() < 1 {
return errors.New("skill required")
}
skill := fs.Arg(0)
res, err := newBrainClient().PassRate(ctx, skill, *window)
if err != nil {
return err
}
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(res)
}
if res.PassRate == nil {
fmt.Fprintf(stdout, "%s: no data (window: %s)\n", res.Skill, res.Window) //nolint:errcheck
return nil
}
fmt.Fprintf(stdout, "%s: %d / %d = %.0f%% (window: %s)\n", res.Skill, res.Pass, res.Total, *res.PassRate*100, res.Window) //nolint:errcheck
return nil
}

View File

@@ -154,3 +154,67 @@ func TestRunBrainWrite_EmptyStdin(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
}
func TestRunBrainPassRate_Human(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
got := out.String()
assert.Contains(t, got, "tdd")
assert.Contains(t, got, "47 / 50")
assert.Contains(t, got, "94%")
}
func TestRunBrainPassRate_NoData(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), "no data")
}
func TestRunBrainPassRate_JSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "--json", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), `"pass_rate": 0.94`)
}
func TestRunBrainPassRate_MissingSkill(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate"}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
assert.Contains(t, err.Error(), "skill required")
}
func TestRunBrainPassRate_WindowFlag(t *testing.T) {
gotWindow := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotWindow = r.URL.Query().Get("window")
_, _ = w.Write([]byte(`{"skill":"tdd","window":"30d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "--window", "30d", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Equal(t, "30d", gotWindow)
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
@@ -119,3 +120,40 @@ func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.R
}
return &out, nil
}
// PassRateResult mirrors the /pass-rate response envelope.
type PassRateResult struct {
Skill string `json:"skill"`
Window string `json:"window"`
Pass int `json:"pass"`
Fail int `json:"fail"`
Skip int `json:"skip"`
Total int `json:"total"`
PassRate *float64 `json:"pass_rate"`
}
func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*PassRateResult, error) {
q := url.Values{}
q.Set("skill", skill)
q.Set("window", window)
u := c.baseURL + "/pass-rate?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("brain GET /pass-rate: status %d: %s", resp.StatusCode, string(body))
}
var out PassRateResult
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode /pass-rate response: %w", err)
}
return &out, nil
}

View File

@@ -84,6 +84,40 @@ func TestBrainClient_Write_Success(t *testing.T) {
assert.Equal(t, "knowledge/find-h.md", res.Path)
}
func TestBrainClient_PassRate_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/pass-rate", r.URL.Path)
assert.Equal(t, "tdd", r.URL.Query().Get("skill"))
assert.Equal(t, "7d", r.URL.Query().Get("window"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
res, err := c.PassRate(context.Background(), "tdd", "7d")
require.NoError(t, err)
assert.Equal(t, "tdd", res.Skill)
assert.Equal(t, 47, res.Pass)
assert.Equal(t, 3, res.Fail)
require.NotNil(t, res.PassRate)
assert.InDelta(t, 0.94, *res.PassRate, 0.001)
}
func TestBrainClient_PassRate_NullRate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
res, err := c.PassRate(context.Background(), "tdd", "7d")
require.NoError(t, err)
assert.Nil(t, res.PassRate)
}
func TestNewBrainClient_DefaultURL(t *testing.T) {
t.Setenv("BRAIN_URL", "")
c := newBrainClient()