From 593d1a4c6deec801f0e8ab69b9bbc8e19939ae74 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 22:44:04 +0200 Subject: [PATCH] feat(hyperguild): brain pass-rate subcommand Adds 'hyperguild brain pass-rate [--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. --- cmd/hyperguild/brain.go | 37 +++++++++++++++++++-- cmd/hyperguild/brain_test.go | 64 ++++++++++++++++++++++++++++++++++++ cmd/hyperguild/http.go | 38 +++++++++++++++++++++ cmd/hyperguild/http_test.go | 34 +++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/cmd/hyperguild/brain.go b/cmd/hyperguild/brain.go index d9d1aab..9d638e8 100644 --- a/cmd/hyperguild/brain.go +++ b/cmd/hyperguild/brain.go @@ -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 +} diff --git a/cmd/hyperguild/brain_test.go b/cmd/hyperguild/brain_test.go index 08f687d..74b4062 100644 --- a/cmd/hyperguild/brain_test.go +++ b/cmd/hyperguild/brain_test.go @@ -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) +} diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go index 6dc1f3a..d3847cb 100644 --- a/cmd/hyperguild/http.go +++ b/cmd/hyperguild/http.go @@ -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 +} diff --git a/cmd/hyperguild/http_test.go b/cmd/hyperguild/http_test.go index d3c77d9..ffa402a 100644 --- a/cmd/hyperguild/http_test.go +++ b/cmd/hyperguild/http_test.go @@ -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()