diff --git a/Makefile b/Makefile index 8ce3795..48d6742 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,13 @@ SHELL = /bin/sh -VERSION=1.8.0 +VERSION=1.9.0 BUILD=`git rev-parse HEAD` LDFLAGS=-ldflags "-w -s \ -X github.com/Betterment/testtrack-cli/cmds.version=${VERSION} \ -X github.com/Betterment/testtrack-cli/cmds.build=${BUILD}" -PACKAGES=$$(find . -maxdepth 1 -type d ! -path '.' ! -path './.*' ! -path './vendor' ! -path './dist' ! -path './script' ! -path './doc') +PACKAGES=$$(find . -maxdepth 1 -type d ! -path '.' ! -path './.*' ! -path './vendor' ! -path './dist' ! -path './script' ! -path './doc' ! -path './docs' ! -path './dev_docs' ! -path './bin') all: test diff --git a/README.md b/README.md index ceeef01..9b15ac8 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ If you have a large organization, you may wish to tag ownership of splits to a s If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL= testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.{json,yml}` file. +### Showing a split's current weights + +To read the current variant weights of a split from a remote server without modifying your local schema, run `TESTTRACK_CLI_URL= testtrack show ` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack show my_app.my_feature_enabled`). Pass the fully-qualified split name; it is matched verbatim against the remote registry. Add `--json` to print the weights as a JSON map for scripting. This also works against a local `testtrack server` (e.g. `TESTTRACK_CLI_URL=http://localhost:8297 testtrack show ...`). + ## How to Contribute We would love for you to contribute! Anything that benefits the majority of TestTrack users—from a documentation fix to an entirely new feature—is encouraged. diff --git a/cmds/show.go b/cmds/show.go new file mode 100644 index 0000000..85ae0da --- /dev/null +++ b/cmds/show.go @@ -0,0 +1,102 @@ +package cmds + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/Betterment/testtrack-cli/serializers" + "github.com/Betterment/testtrack-cli/servers" + "github.com/spf13/cobra" +) + +var showJSON bool + +var showDoc = ` +Show the current variant weights for a split from the remote TestTrack server. + +Reads the split registry from the server configured by TESTTRACK_CLI_URL and +prints the weights for the named split. It does not modify the local schema. + +The split name is matched verbatim against the remote registry, so pass the +fully-qualified name (e.g. my_app.my_feature_enabled). +` + +func init() { + showCommand.Flags().BoolVar(&showJSON, "json", false, "output weights as JSON") + rootCmd.AddCommand(showCommand) +} + +var showCommand = &cobra.Command{ + Use: "show ", + Short: "Show remote variant weights for a split", + Long: showDoc, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return Show(args[0], showJSON) + }, +} + +// Show prints the remote variant weights for the named split. +func Show(name string, asJSON bool) error { + server, err := servers.New() + if err != nil { + return err + } + + output, err := splitWeights(server, name, asJSON) + if err != nil { + return err + } + + fmt.Println(output) + return nil +} + +// splitWeights fetches the named split from the server and renders its weights. +func splitWeights(server servers.IServer, name string, asJSON bool) (string, error) { + var registry serializers.RemoteRegistry + if err := server.Get("api/v2/split_registry", ®istry); err != nil { + return "", err + } + + split, ok := registry.Splits[name] + if !ok { + return "", fmt.Errorf("split %q not found in remote registry; check the name or run `testtrack sync`", name) + } + + return formatWeights(name, split.Weights, asJSON) +} + +// formatWeights renders a split's variant weights for display. +func formatWeights(name string, weights map[string]int, asJSON bool) (string, error) { + if asJSON { + bytes, err := json.Marshal(weights) + if err != nil { + return "", err + } + return string(bytes), nil + } + + variants := make([]string, 0, len(weights)) + nameWidth, weightWidth := 0, 0 + for variant, weight := range weights { + variants = append(variants, variant) + if len(variant) > nameWidth { + nameWidth = len(variant) + } + if w := len(strconv.Itoa(weight)); w > weightWidth { + weightWidth = w + } + } + sort.Strings(variants) + + var b strings.Builder + b.WriteString(name) + for _, variant := range variants { + fmt.Fprintf(&b, "\n %-*s %*d%%", nameWidth, variant, weightWidth, weights[variant]) + } + return b.String(), nil +} diff --git a/cmds/show_test.go b/cmds/show_test.go new file mode 100644 index 0000000..fe8ac8a --- /dev/null +++ b/cmds/show_test.go @@ -0,0 +1,89 @@ +package cmds + +import ( + "net/http" + "testing" + + "github.com/Betterment/testtrack-cli/serializers" + "github.com/stretchr/testify/require" +) + +// fakeServer is a test double for servers.IServer that returns a canned registry. +type fakeServer struct { + registry serializers.RemoteRegistry +} + +func (f *fakeServer) Get(_ string, v interface{}) error { + *v.(*serializers.RemoteRegistry) = f.registry + return nil +} + +func (f *fakeServer) Post(_ string, _ interface{}) (*http.Response, error) { + return nil, nil +} + +func (f *fakeServer) Delete(_ string) error { + return nil +} + +func registryWith(name string, weights map[string]int) serializers.RemoteRegistry { + return serializers.RemoteRegistry{ + Splits: map[string]serializers.RemoteRegistrySplit{ + name: {Weights: weights}, + }, + } +} + +func TestSplitWeightsHumanReadable(t *testing.T) { + name := "retail.cash_in_portfolios_q2_2026_enabled" + server := &fakeServer{registry: registryWith(name, map[string]int{"false": 100, "true": 0})} + + output, err := splitWeights(server, name, false) + require.NoError(t, err) + + // variant names are left-padded to the widest name, weights right-aligned, + // and variants sorted: "false" before "true" + expected := name + "\n false 100%\n true 0%" + require.Equal(t, expected, output) +} + +func TestSplitWeightsAlignsToWidestVariantAndWeight(t *testing.T) { + name := "my_app.checkout_experiment" + server := &fakeServer{registry: registryWith(name, map[string]int{ + "control": 5, + "treatment": 95, + })} + + output, err := splitWeights(server, name, false) + require.NoError(t, err) + + // names left-aligned to "treatment" (9), weights right-aligned to width 2 + expected := name + "\n control 5%\n treatment 95%" + require.Equal(t, expected, output) +} + +func TestSplitWeightsJSON(t *testing.T) { + name := "retail.cash_in_portfolios_q2_2026_enabled" + weights := map[string]int{"false": 100, "true": 0} + server := &fakeServer{registry: registryWith(name, weights)} + + output, err := splitWeights(server, name, true) + require.NoError(t, err) + require.JSONEq(t, `{"false":100,"true":0}`, output) +} + +func TestShowRequiresServerURL(t *testing.T) { + t.Setenv("TESTTRACK_CLI_URL", "") + + err := Show("any.split", false) + require.Error(t, err) +} + +func TestSplitWeightsNotFound(t *testing.T) { + server := &fakeServer{registry: registryWith("some.other.split", map[string]int{"true": 100})} + + _, err := splitWeights(server, "no.such.split", false) + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + require.Contains(t, err.Error(), "testtrack sync") +}