From de12d504b81f6d00d552b7e6feaa504ccacdf282 Mon Sep 17 00:00:00 2001 From: maragubot Date: Fri, 27 Feb 2026 13:34:45 +0100 Subject: [PATCH 1/5] Set up project foundation for Honeycomb CLI - Update module path to github.com/maragudk/honeycomb-cli - Add cobra CLI framework with root command and global flags - Support --api-key/HONEYCOMB_API_KEY and --api-url/HONEYCOMB_API_URL - Add version command (set via -ldflags at build time) - Add basic test for Execute function - Update README with install and usage instructions Closes #1 --- AGENTS.md | 14 ++++++++++++++ README.md | 28 ++++++++++++++++++++++----- cmd/root.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root_test.go | 16 ++++++++++++++++ cmd/version.go | 20 ++++++++++++++++++++ go.mod | 12 +++++++++++- go.sum | 12 ++++++++++++ main.go | 11 +++++++++++ template.go | 1 - 9 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 cmd/root.go create mode 100644 cmd/root_test.go create mode 100644 cmd/version.go create mode 100644 go.sum create mode 100644 main.go delete mode 100644 template.go diff --git a/AGENTS.md b/AGENTS.md index 2e0aace..6ffe232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1,15 @@ # Dear Agent + +This is a CLI tool for the Honeycomb.io API, written in Go. + +## Structure + +- `main.go` - entry point +- `cmd/` - cobra command definitions (root, version, etc.) +- `honeycomb/` - API client package + +## Install + +```shell +go install github.com/maragudk/honeycomb-cli@latest +``` diff --git a/README.md b/README.md index d9ced71..7abdf8f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,26 @@ -# template +# honeycomb-cli -[![Docs](https://pkg.go.dev/badge/maragu.dev/template)](https://pkg.go.dev/maragu.dev/template) -[![CI](https://github.com/maragudk/template/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/template/actions/workflows/ci.yml) +[![CI](https://github.com/maragudk/honeycomb-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/honeycomb-cli/actions/workflows/ci.yml) -Made with ✨sparkles✨ by [maragu](https://www.maragu.dev/): independent software consulting for cloud-native Go apps & AI engineering. +A command-line interface for the [Honeycomb.io](https://www.honeycomb.io/) observability platform. -[Contact me at markus@maragu.dk](mailto:markus@maragu.dk) for consulting work, or perhaps an invoice to support this project? +Made with sparkles by [maragu](https://www.maragu.dev/). + +## Install + +```shell +go install github.com/maragudk/honeycomb-cli@latest +``` + +## Usage + +```shell +# Set your API key +export HONEYCOMB_API_KEY=your-api-key + +# Or pass it as a flag +honeycomb-cli --api-key your-api-key + +# Verify your API key +honeycomb-cli auth +``` diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..c00166b --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "honeycomb-cli", + Short: "A CLI for the Honeycomb.io API", + Long: "A command-line interface for interacting with the Honeycomb.io observability platform.", +} + +// Execute the root command and return an exit code. +func Execute() int { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} + +func init() { + rootCmd.PersistentFlags().String("api-key", "", "Honeycomb API key (or set HONEYCOMB_API_KEY)") + rootCmd.PersistentFlags().String("api-url", "https://api.honeycomb.io", "Honeycomb API URL (or set HONEYCOMB_API_URL)") +} + +// apiKey returns the API key from the flag or environment variable. +func apiKey(cmd *cobra.Command) string { + key, _ := cmd.Flags().GetString("api-key") + if key != "" { + return key + } + return os.Getenv("HONEYCOMB_API_KEY") +} + +// apiURL returns the API URL from the flag or environment variable. +func apiURL(cmd *cobra.Command) string { + url, _ := cmd.Flags().GetString("api-url") + if url != "https://api.honeycomb.io" { + return url + } + if envURL := os.Getenv("HONEYCOMB_API_URL"); envURL != "" { + return envURL + } + return url +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..5c7b0cd --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,16 @@ +package cmd_test + +import ( + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" +) + +func TestExecute(t *testing.T) { + t.Run("returns 0 on success", func(t *testing.T) { + code := cmd.Execute() + is.Equal(t, 0, code) + }) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..2eb6361 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// version is set at build time via -ldflags. +var version = "dev" + +func init() { + rootCmd.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print the version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(version) + }, + }) +} diff --git a/go.mod b/go.mod index f52a35e..38a8e78 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,13 @@ -module maragu.dev/template +module github.com/maragudk/honeycomb-cli go 1.26 + +require ( + github.com/spf13/cobra v1.10.2 + maragu.dev/is v0.3.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf67f6c --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +maragu.dev/is v0.3.1 h1:1sj4Ewc9Ecqtvp1Aro+kRCpnuu4D5CB8w//GOfM7jFs= +maragu.dev/is v0.3.1/go.mod h1:bviaM5S0fBshCw7wuumFGTju/izopZ/Yvq4g7Klc7y8= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e345bdb --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/maragudk/honeycomb-cli/cmd" +) + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/template.go b/template.go deleted file mode 100644 index 38cdfe4..0000000 --- a/template.go +++ /dev/null @@ -1 +0,0 @@ -package template From 130ffe8222d2cc212016a8eaf1397d5d1571684c Mon Sep 17 00:00:00 2001 From: maragubot Date: Fri, 27 Feb 2026 13:37:10 +0100 Subject: [PATCH 2/5] Add Honeycomb API client and auth command - Create honeycomb package with HTTP client supporting API key auth - Add error response parsing (RFC 7807 Problem Detail format) - Implement GET /1/auth endpoint for key verification - Add `auth` command showing team, environment, and permissions - Refactor commands to use NewRootCommand() for testability - Add tests for client, auth API, and auth command Closes #2 Closes #3 --- cmd/auth.go | 41 +++++++++++++++++ cmd/auth_test.go | 70 ++++++++++++++++++++++++++++ cmd/root.go | 28 ++++++++---- cmd/version.go | 8 ++-- honeycomb/auth.go | 48 ++++++++++++++++++++ honeycomb/client.go | 98 ++++++++++++++++++++++++++++++++++++++++ honeycomb/client_test.go | 76 +++++++++++++++++++++++++++++++ 7 files changed, 355 insertions(+), 14 deletions(-) create mode 100644 cmd/auth.go create mode 100644 cmd/auth_test.go create mode 100644 honeycomb/auth.go create mode 100644 honeycomb/client.go create mode 100644 honeycomb/client_test.go diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..f289164 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func newAuthCommand() *cobra.Command { + return &cobra.Command{ + Use: "auth", + Short: "Verify your API key and show team/environment info", + RunE: func(cmd *cobra.Command, args []string) error { + key := apiKey(cmd) + if key == "" { + return fmt.Errorf("API key is required (set HONEYCOMB_API_KEY or use --api-key)") + } + + c := honeycomb.NewClient(key, honeycomb.WithBaseURL(apiURL(cmd))) + auth, err := c.Auth(cmd.Context()) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Team: %v\n", auth.Team.Name) + fmt.Fprintf(cmd.OutOrStdout(), "Environment: %v\n", auth.Environment.Name) + + if len(auth.APIKeyAccess) > 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Permissions:") + for perm, granted := range auth.APIKeyAccess { + if granted { + fmt.Fprintf(cmd.OutOrStdout(), " %v\n", perm) + } + } + } + return nil + }, + } +} diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..a525d69 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,70 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestAuthCommand(t *testing.T) { + t.Run("displays team and environment info", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(honeycomb.AuthResponse{ + Team: honeycomb.AuthTeam{ + Name: "Acme Corp", + }, + Environment: honeycomb.AuthEnvironment{ + Name: "Production", + }, + APIKeyAccess: map[string]bool{ + "events": true, + }, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"auth", "--api-key", "test-key", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "Acme Corp")) + is.True(t, contains(output, "Production")) + is.True(t, contains(output, "events")) + }) + + t.Run("returns error when API key is missing", func(t *testing.T) { + root := cmd.NewRootCommand() + root.SetArgs([]string{"auth"}) + + // Clear the env var for this test + t.Setenv("HONEYCOMB_API_KEY", "") + + err := root.Execute() + is.True(t, err != nil) + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/root.go b/cmd/root.go index c00166b..2404189 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,26 +7,34 @@ import ( "github.com/spf13/cobra" ) -var rootCmd = &cobra.Command{ - Use: "honeycomb-cli", - Short: "A CLI for the Honeycomb.io API", - Long: "A command-line interface for interacting with the Honeycomb.io observability platform.", +// NewRootCommand creates a new root command with all subcommands registered. +func NewRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "honeycomb-cli", + Short: "A CLI for the Honeycomb.io API", + Long: "A command-line interface for interacting with the Honeycomb.io observability platform.", + SilenceUsage: true, + SilenceErrors: true, + } + + root.PersistentFlags().String("api-key", "", "Honeycomb API key (or set HONEYCOMB_API_KEY)") + root.PersistentFlags().String("api-url", "https://api.honeycomb.io", "Honeycomb API URL (or set HONEYCOMB_API_URL)") + + root.AddCommand(newVersionCommand()) + root.AddCommand(newAuthCommand()) + + return root } // Execute the root command and return an exit code. func Execute() int { - if err := rootCmd.Execute(); err != nil { + if err := NewRootCommand().Execute(); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } return 0 } -func init() { - rootCmd.PersistentFlags().String("api-key", "", "Honeycomb API key (or set HONEYCOMB_API_KEY)") - rootCmd.PersistentFlags().String("api-url", "https://api.honeycomb.io", "Honeycomb API URL (or set HONEYCOMB_API_URL)") -} - // apiKey returns the API key from the flag or environment variable. func apiKey(cmd *cobra.Command) string { key, _ := cmd.Flags().GetString("api-key") diff --git a/cmd/version.go b/cmd/version.go index 2eb6361..30e77a4 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -9,12 +9,12 @@ import ( // version is set at build time via -ldflags. var version = "dev" -func init() { - rootCmd.AddCommand(&cobra.Command{ +func newVersionCommand() *cobra.Command { + return &cobra.Command{ Use: "version", Short: "Print the version", Run: func(cmd *cobra.Command, args []string) { - fmt.Println(version) + fmt.Fprintln(cmd.OutOrStdout(), version) }, - }) + } } diff --git a/honeycomb/auth.go b/honeycomb/auth.go new file mode 100644 index 0000000..522ab91 --- /dev/null +++ b/honeycomb/auth.go @@ -0,0 +1,48 @@ +package honeycomb + +import ( + "context" + "encoding/json" + "net/http" +) + +// AuthResponse from the Honeycomb auth endpoint. +type AuthResponse struct { + Team AuthTeam `json:"team"` + Environment AuthEnvironment `json:"environment"` + APIKeyAccess map[string]bool `json:"api_key_access"` +} + +// AuthTeam information. +type AuthTeam struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +// AuthEnvironment information. +type AuthEnvironment struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +// Auth verifies the API key and returns team and environment information. +func (c *Client) Auth(ctx context.Context) (*AuthResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/auth", nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var auth AuthResponse + if err := json.NewDecoder(res.Body).Decode(&auth); err != nil { + return nil, err + } + return &auth, nil +} diff --git a/honeycomb/client.go b/honeycomb/client.go new file mode 100644 index 0000000..446d60f --- /dev/null +++ b/honeycomb/client.go @@ -0,0 +1,98 @@ +// Package honeycomb provides a client for the Honeycomb.io API. +package honeycomb + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client for the Honeycomb API. +type Client struct { + apiKey string + baseURL string + http *http.Client +} + +// NewClient with the given API key and options. +func NewClient(apiKey string, opts ...Option) *Client { + c := &Client{ + apiKey: apiKey, + baseURL: "https://api.honeycomb.io", + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// Option for configuring a [Client]. +type Option func(*Client) + +// WithBaseURL sets the base URL for the API. +func WithBaseURL(url string) Option { + return func(c *Client) { + c.baseURL = url + } +} + +// WithHTTPClient sets the underlying HTTP client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { + c.http = hc + } +} + +// APIError returned from the Honeycomb API. +type APIError struct { + StatusCode int + Status int `json:"status"` + Type string `json:"type"` + Title string `json:"title"` + Detail string `json:"detail"` + Err string `json:"error"` +} + +func (e *APIError) Error() string { + if e.Detail != "" { + return fmt.Sprintf("honeycomb API error (%d): %v", e.StatusCode, e.Detail) + } + if e.Err != "" { + return fmt.Sprintf("honeycomb API error (%d): %v", e.StatusCode, e.Err) + } + return fmt.Sprintf("honeycomb API error (%d): %v", e.StatusCode, e.Title) +} + +func (c *Client) do(req *http.Request) (*http.Response, error) { + req.Header.Set("X-Honeycomb-Team", c.apiKey) + req.Header.Set("Content-Type", "application/json") + + res, err := c.http.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode >= 400 { + defer func() { + _ = res.Body.Close() + }() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("reading error response: %w", err) + } + + apiErr := &APIError{StatusCode: res.StatusCode} + if err := json.Unmarshal(body, apiErr); err != nil { + return nil, fmt.Errorf("honeycomb API error (%d): %v", res.StatusCode, string(body)) + } + return nil, apiErr + } + + return res, nil +} diff --git a/honeycomb/client_test.go b/honeycomb/client_test.go new file mode 100644 index 0000000..678b2bf --- /dev/null +++ b/honeycomb/client_test.go @@ -0,0 +1,76 @@ +package honeycomb_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestNewClient(t *testing.T) { + t.Run("uses default base URL", func(t *testing.T) { + c := honeycomb.NewClient("test-key") + is.NotNil(t, c) + }) + + t.Run("accepts custom base URL", func(t *testing.T) { + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL("https://custom.example.com")) + is.NotNil(t, c) + }) +} + +func TestClient_Auth(t *testing.T) { + t.Run("returns auth info for a valid API key", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/auth", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + is.Equal(t, "test-key", r.Header.Get("X-Honeycomb-Team")) + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(honeycomb.AuthResponse{ + Team: honeycomb.AuthTeam{ + Name: "My Team", + Slug: "my-team", + }, + Environment: honeycomb.AuthEnvironment{ + Name: "Production", + Slug: "production", + }, + APIKeyAccess: map[string]bool{ + "events": true, + "markers": true, + }, + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + auth, err := c.Auth(t.Context()) + is.NotError(t, err) + is.Equal(t, "My Team", auth.Team.Name) + is.Equal(t, "my-team", auth.Team.Slug) + is.Equal(t, "Production", auth.Environment.Name) + is.Equal(t, "production", auth.Environment.Slug) + is.True(t, auth.APIKeyAccess["events"]) + is.True(t, auth.APIKeyAccess["markers"]) + }) + + t.Run("returns error for invalid API key", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": 401, + "error": "unknown API key - check your credentials", + }) + })) + defer server.Close() + + c := honeycomb.NewClient("bad-key", honeycomb.WithBaseURL(server.URL)) + _, err := c.Auth(t.Context()) + is.True(t, err != nil) + }) +} From 54b43bdf0bef829b9537975e468657befa025650 Mon Sep 17 00:00:00 2001 From: maragubot Date: Fri, 27 Feb 2026 13:39:00 +0100 Subject: [PATCH 3/5] Add datasets commands: list, get, create, delete - Add Dataset types and API methods to honeycomb package - Add datasets subcommands with table and JSON output - Add newClient helper for creating API clients from flags - Tests for all API methods and commands Closes #4 --- cmd/datasets.go | 129 ++++++++++++++++++++++++++++++++++++ cmd/datasets_test.go | 130 +++++++++++++++++++++++++++++++++++++ cmd/root.go | 8 +++ honeycomb/datasets.go | 112 ++++++++++++++++++++++++++++++++ honeycomb/datasets_test.go | 96 +++++++++++++++++++++++++++ 5 files changed, 475 insertions(+) create mode 100644 cmd/datasets.go create mode 100644 cmd/datasets_test.go create mode 100644 honeycomb/datasets.go create mode 100644 honeycomb/datasets_test.go diff --git a/cmd/datasets.go b/cmd/datasets.go new file mode 100644 index 0000000..564eff0 --- /dev/null +++ b/cmd/datasets.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func newDatasetsCommand() *cobra.Command { + datasetsCmd := &cobra.Command{ + Use: "datasets", + Short: "Manage datasets", + } + + datasetsCmd.AddCommand(newDatasetsListCommand()) + datasetsCmd.AddCommand(newDatasetsGetCommand()) + datasetsCmd.AddCommand(newDatasetsCreateCommand()) + datasetsCmd.AddCommand(newDatasetsDeleteCommand()) + + return datasetsCmd +} + +func newDatasetsListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all datasets", + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + datasets, err := c.ListDatasets(cmd.Context()) + if err != nil { + return err + } + + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(datasets) + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSLUG\tDESCRIPTION\tLAST WRITTEN") + for _, d := range datasets { + fmt.Fprintf(w, "%v\t%v\t%v\t%v\n", d.Name, d.Slug, d.Description, d.LastWrittenAt) + } + return w.Flush() + }, + } + cmd.Flags().Bool("json", false, "Output as JSON") + return cmd +} + +func newDatasetsGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a dataset by slug", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + dataset, err := c.GetDataset(cmd.Context(), args[0]) + if err != nil { + return err + } + + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(dataset) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Name: %v\n", dataset.Name) + fmt.Fprintf(cmd.OutOrStdout(), "Slug: %v\n", dataset.Slug) + fmt.Fprintf(cmd.OutOrStdout(), "Description: %v\n", dataset.Description) + fmt.Fprintf(cmd.OutOrStdout(), "Last written: %v\n", dataset.LastWrittenAt) + return nil + }, + } + cmd.Flags().Bool("json", false, "Output as JSON") + return cmd +} + +func newDatasetsCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a dataset", + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + dataset, err := c.CreateDataset(cmd.Context(), honeycomb.CreateDatasetRequest{ + Name: name, + Description: description, + }) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created dataset %q (%v)\n", dataset.Name, dataset.Slug) + return nil + }, + } + cmd.Flags().String("name", "", "Dataset name") + _ = cmd.MarkFlagRequired("name") + cmd.Flags().String("description", "", "Dataset description") + return cmd +} + +func newDatasetsDeleteCommand() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a dataset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + + if err := c.DeleteDataset(cmd.Context(), args[0]); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Deleted dataset %q\n", args[0]) + return nil + }, + } +} diff --git a/cmd/datasets_test.go b/cmd/datasets_test.go new file mode 100644 index 0000000..96b9ee7 --- /dev/null +++ b/cmd/datasets_test.go @@ -0,0 +1,130 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestDatasetsListCommand(t *testing.T) { + t.Run("lists datasets in a table", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]honeycomb.Dataset{ + {Name: "requests", Slug: "requests", Description: "HTTP requests"}, + {Name: "errors", Slug: "errors"}, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "list", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "requests")) + is.True(t, contains(output, "errors")) + is.True(t, contains(output, "NAME")) + }) + + t.Run("lists datasets as JSON", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]honeycomb.Dataset{ + {Name: "requests", Slug: "requests"}, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "list", "--json", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + var datasets []honeycomb.Dataset + is.NotError(t, json.Unmarshal(buf.Bytes(), &datasets)) + is.Equal(t, 1, len(datasets)) + is.Equal(t, "requests", datasets[0].Name) + }) +} + +func TestDatasetsGetCommand(t *testing.T) { + t.Run("shows dataset details", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "requests", + Slug: "requests", + Description: "HTTP requests", + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "get", "requests", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "requests")) + is.True(t, contains(output, "HTTP requests")) + }) +} + +func TestDatasetsCreateCommand(t *testing.T) { + t.Run("creates a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "new-dataset", + Slug: "new-dataset", + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "create", "--name", "new-dataset", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + is.True(t, contains(buf.String(), "new-dataset")) + }) +} + +func TestDatasetsDeleteCommand(t *testing.T) { + t.Run("deletes a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"datasets", "delete", "old-dataset", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + is.True(t, contains(buf.String(), "old-dataset")) + }) +} diff --git a/cmd/root.go b/cmd/root.go index 2404189..c94bbb2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,8 @@ import ( "os" "github.com/spf13/cobra" + + "github.com/maragudk/honeycomb-cli/honeycomb" ) // NewRootCommand creates a new root command with all subcommands registered. @@ -22,6 +24,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newVersionCommand()) root.AddCommand(newAuthCommand()) + root.AddCommand(newDatasetsCommand()) return root } @@ -55,3 +58,8 @@ func apiURL(cmd *cobra.Command) string { } return url } + +// newClient creates a new Honeycomb API client from the command's flags. +func newClient(cmd *cobra.Command) *honeycomb.Client { + return honeycomb.NewClient(apiKey(cmd), honeycomb.WithBaseURL(apiURL(cmd))) +} diff --git a/honeycomb/datasets.go b/honeycomb/datasets.go new file mode 100644 index 0000000..4348319 --- /dev/null +++ b/honeycomb/datasets.go @@ -0,0 +1,112 @@ +package honeycomb + +import ( + "bytes" + "context" + "encoding/json" + "net/http" +) + +// Dataset in Honeycomb. +type Dataset struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + LastWrittenAt string `json:"last_written_at,omitempty"` + RegularColumns int `json:"regular_columns,omitempty"` + ExpandJSONDepth int `json:"expand_json_depth,omitempty"` +} + +// ListDatasets in the environment. +func (c *Client) ListDatasets(ctx context.Context) ([]Dataset, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/datasets", nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var datasets []Dataset + if err := json.NewDecoder(res.Body).Decode(&datasets); err != nil { + return nil, err + } + return datasets, nil +} + +// GetDataset by slug. +func (c *Client) GetDataset(ctx context.Context, slug string) (*Dataset, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/datasets/"+slug, nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var dataset Dataset + if err := json.NewDecoder(res.Body).Decode(&dataset); err != nil { + return nil, err + } + return &dataset, nil +} + +// CreateDatasetRequest for creating a dataset. +type CreateDatasetRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ExpandJSONDepth int `json:"expand_json_depth,omitempty"` +} + +// CreateDataset with the given name. +func (c *Client) CreateDataset(ctx context.Context, create CreateDatasetRequest) (*Dataset, error) { + body, err := json.Marshal(create) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/1/datasets", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var dataset Dataset + if err := json.NewDecoder(res.Body).Decode(&dataset); err != nil { + return nil, err + } + return &dataset, nil +} + +// DeleteDataset by slug. +func (c *Client) DeleteDataset(ctx context.Context, slug string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+"/1/datasets/"+slug, nil) + if err != nil { + return err + } + + res, err := c.do(req) + if err != nil { + return err + } + _ = res.Body.Close() + return nil +} diff --git a/honeycomb/datasets_test.go b/honeycomb/datasets_test.go new file mode 100644 index 0000000..d0892b4 --- /dev/null +++ b/honeycomb/datasets_test.go @@ -0,0 +1,96 @@ +package honeycomb_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestClient_ListDatasets(t *testing.T) { + t.Run("returns list of datasets", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + + _ = json.NewEncoder(w).Encode([]honeycomb.Dataset{ + {Name: "requests", Slug: "requests"}, + {Name: "errors", Slug: "errors"}, + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + datasets, err := c.ListDatasets(t.Context()) + is.NotError(t, err) + is.Equal(t, 2, len(datasets)) + is.Equal(t, "requests", datasets[0].Name) + is.Equal(t, "errors", datasets[1].Name) + }) +} + +func TestClient_GetDataset(t *testing.T) { + t.Run("returns dataset by slug", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets/requests", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "requests", + Slug: "requests", + Description: "HTTP requests", + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + dataset, err := c.GetDataset(t.Context(), "requests") + is.NotError(t, err) + is.Equal(t, "requests", dataset.Name) + is.Equal(t, "HTTP requests", dataset.Description) + }) +} + +func TestClient_CreateDataset(t *testing.T) { + t.Run("creates a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets", r.URL.Path) + is.Equal(t, http.MethodPost, r.Method) + + var req honeycomb.CreateDatasetRequest + _ = json.NewDecoder(r.Body).Decode(&req) + is.Equal(t, "new-dataset", req.Name) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(honeycomb.Dataset{ + Name: "new-dataset", + Slug: "new-dataset", + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + dataset, err := c.CreateDataset(t.Context(), honeycomb.CreateDatasetRequest{Name: "new-dataset"}) + is.NotError(t, err) + is.Equal(t, "new-dataset", dataset.Name) + }) +} + +func TestClient_DeleteDataset(t *testing.T) { + t.Run("deletes a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/datasets/old-dataset", r.URL.Path) + is.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + err := c.DeleteDataset(t.Context(), "old-dataset") + is.NotError(t, err) + }) +} From 52f3d5180e4d23f8f9d4e05be45d3bec504a613c Mon Sep 17 00:00:00 2001 From: maragubot Date: Fri, 27 Feb 2026 13:40:22 +0100 Subject: [PATCH 4/5] Add markers commands: list, create, delete - Add Marker types and API methods for list, create, delete - Support --dataset flag (defaults to __all__ for environment-wide) - Markers are useful for annotating deploys in CI/CD pipelines - Table and JSON output formatting - Tests for all API methods and commands Closes #5 --- cmd/markers.go | 106 ++++++++++++++++++++++++++++++++++++++ cmd/markers_test.go | 105 +++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + honeycomb/markers.go | 94 +++++++++++++++++++++++++++++++++ honeycomb/markers_test.go | 79 ++++++++++++++++++++++++++++ 5 files changed, 385 insertions(+) create mode 100644 cmd/markers.go create mode 100644 cmd/markers_test.go create mode 100644 honeycomb/markers.go create mode 100644 honeycomb/markers_test.go diff --git a/cmd/markers.go b/cmd/markers.go new file mode 100644 index 0000000..0ea5f3c --- /dev/null +++ b/cmd/markers.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func newMarkersCommand() *cobra.Command { + markersCmd := &cobra.Command{ + Use: "markers", + Short: "Manage markers (deploy annotations, etc.)", + } + + markersCmd.PersistentFlags().String("dataset", "__all__", "Dataset slug (use __all__ for environment-wide)") + + markersCmd.AddCommand(newMarkersListCommand()) + markersCmd.AddCommand(newMarkersCreateCommand()) + markersCmd.AddCommand(newMarkersDeleteCommand()) + + return markersCmd +} + +func newMarkersListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List markers", + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + dataset, _ := cmd.Flags().GetString("dataset") + + markers, err := c.ListMarkers(cmd.Context(), dataset) + if err != nil { + return err + } + + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(markers) + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tTYPE\tMESSAGE\tCREATED") + for _, m := range markers { + fmt.Fprintf(w, "%v\t%v\t%v\t%v\n", m.ID, m.Type, m.Message, m.CreatedAt) + } + return w.Flush() + }, + } + cmd.Flags().Bool("json", false, "Output as JSON") + return cmd +} + +func newMarkersCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a marker", + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + dataset, _ := cmd.Flags().GetString("dataset") + + markerType, _ := cmd.Flags().GetString("type") + message, _ := cmd.Flags().GetString("message") + url, _ := cmd.Flags().GetString("url") + + marker, err := c.CreateMarker(cmd.Context(), dataset, honeycomb.CreateMarkerRequest{ + Type: markerType, + Message: message, + URL: url, + }) + if err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created marker %v (type: %v)\n", marker.ID, marker.Type) + return nil + }, + } + cmd.Flags().String("type", "", "Marker type (e.g. deploy)") + cmd.Flags().String("message", "", "Marker message") + cmd.Flags().String("url", "", "URL to associate with the marker") + return cmd +} + +func newMarkersDeleteCommand() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a marker", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + dataset, _ := cmd.Flags().GetString("dataset") + + if err := c.DeleteMarker(cmd.Context(), dataset, args[0]); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "Deleted marker %v\n", args[0]) + return nil + }, + } +} diff --git a/cmd/markers_test.go b/cmd/markers_test.go new file mode 100644 index 0000000..4d6f1c1 --- /dev/null +++ b/cmd/markers_test.go @@ -0,0 +1,105 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestMarkersListCommand(t *testing.T) { + t.Run("lists markers in a table", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/markers/__all__", r.URL.Path) + _ = json.NewEncoder(w).Encode([]honeycomb.Marker{ + {ID: "abc", Type: "deploy", Message: "v1.0.0"}, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"markers", "list", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "deploy")) + is.True(t, contains(output, "v1.0.0")) + }) + + t.Run("lists markers for a specific dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/markers/my-dataset", r.URL.Path) + _ = json.NewEncoder(w).Encode([]honeycomb.Marker{}) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"markers", "list", "--dataset", "my-dataset", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + }) +} + +func TestMarkersCreateCommand(t *testing.T) { + t.Run("creates a deploy marker", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, http.MethodPost, r.Method) + + var req honeycomb.CreateMarkerRequest + _ = json.NewDecoder(r.Body).Decode(&req) + is.Equal(t, "deploy", req.Type) + is.Equal(t, "v2.0.0", req.Message) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(honeycomb.Marker{ + ID: "new123", + Type: req.Type, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"markers", "create", "--type", "deploy", "--message", "v2.0.0", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + is.True(t, contains(buf.String(), "new123")) + }) +} + +func TestMarkersDeleteCommand(t *testing.T) { + t.Run("deletes a marker", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, http.MethodDelete, r.Method) + is.Equal(t, "/1/markers/__all__/abc123", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"markers", "delete", "abc123", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + is.True(t, contains(buf.String(), "abc123")) + }) +} diff --git a/cmd/root.go b/cmd/root.go index c94bbb2..e5469ee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newVersionCommand()) root.AddCommand(newAuthCommand()) root.AddCommand(newDatasetsCommand()) + root.AddCommand(newMarkersCommand()) return root } diff --git a/honeycomb/markers.go b/honeycomb/markers.go new file mode 100644 index 0000000..1b6c082 --- /dev/null +++ b/honeycomb/markers.go @@ -0,0 +1,94 @@ +package honeycomb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Marker in Honeycomb. Markers annotate points in time on graphs (e.g. deploys). +type Marker struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + URL string `json:"url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + StartTime int64 `json:"start_time,omitempty"` + EndTime int64 `json:"end_time,omitempty"` +} + +// CreateMarkerRequest for creating a marker. +type CreateMarkerRequest struct { + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + URL string `json:"url,omitempty"` + StartTime int64 `json:"start_time,omitempty"` + EndTime int64 `json:"end_time,omitempty"` +} + +// ListMarkers for a dataset. Use "__all__" for environment-wide markers. +func (c *Client) ListMarkers(ctx context.Context, dataset string) ([]Marker, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/markers/"+dataset, nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var markers []Marker + if err := json.NewDecoder(res.Body).Decode(&markers); err != nil { + return nil, err + } + return markers, nil +} + +// CreateMarker for a dataset. Use "__all__" for environment-wide markers. +func (c *Client) CreateMarker(ctx context.Context, dataset string, create CreateMarkerRequest) (*Marker, error) { + body, err := json.Marshal(create) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/1/markers/"+dataset, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var marker Marker + if err := json.NewDecoder(res.Body).Decode(&marker); err != nil { + return nil, err + } + return &marker, nil +} + +// DeleteMarker by ID for a dataset. +func (c *Client) DeleteMarker(ctx context.Context, dataset, id string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fmt.Sprintf("%v/1/markers/%v/%v", c.baseURL, dataset, id), nil) + if err != nil { + return err + } + + res, err := c.do(req) + if err != nil { + return err + } + _ = res.Body.Close() + return nil +} diff --git a/honeycomb/markers_test.go b/honeycomb/markers_test.go new file mode 100644 index 0000000..9aca5d5 --- /dev/null +++ b/honeycomb/markers_test.go @@ -0,0 +1,79 @@ +package honeycomb_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestClient_ListMarkers(t *testing.T) { + t.Run("returns markers for a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/markers/requests", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + + _ = json.NewEncoder(w).Encode([]honeycomb.Marker{ + {ID: "abc123", Type: "deploy", Message: "v1.0.0"}, + {ID: "def456", Type: "deploy", Message: "v1.1.0"}, + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + markers, err := c.ListMarkers(t.Context(), "requests") + is.NotError(t, err) + is.Equal(t, 2, len(markers)) + is.Equal(t, "v1.0.0", markers[0].Message) + }) +} + +func TestClient_CreateMarker(t *testing.T) { + t.Run("creates a marker for a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/markers/requests", r.URL.Path) + is.Equal(t, http.MethodPost, r.Method) + + var req honeycomb.CreateMarkerRequest + _ = json.NewDecoder(r.Body).Decode(&req) + is.Equal(t, "deploy", req.Type) + is.Equal(t, "v2.0.0 shipped", req.Message) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(honeycomb.Marker{ + ID: "new123", + Type: req.Type, + Message: req.Message, + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + marker, err := c.CreateMarker(t.Context(), "requests", honeycomb.CreateMarkerRequest{ + Type: "deploy", + Message: "v2.0.0 shipped", + }) + is.NotError(t, err) + is.Equal(t, "new123", marker.ID) + is.Equal(t, "deploy", marker.Type) + }) +} + +func TestClient_DeleteMarker(t *testing.T) { + t.Run("deletes a marker", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/markers/requests/abc123", r.URL.Path) + is.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + err := c.DeleteMarker(t.Context(), "requests", "abc123") + is.NotError(t, err) + }) +} From 51e017b971a99d5c141c0699d4c692eb1277417c Mon Sep 17 00:00:00 2001 From: maragubot Date: Fri, 27 Feb 2026 13:41:24 +0100 Subject: [PATCH 5/5] Add columns list command - Add Column types and ListColumns API method - Add columns list command with --dataset flag (required) - Table and JSON output formatting - Tests for API method and command Closes #6 --- cmd/columns.go | 57 +++++++++++++++++++++++++++++++++++ cmd/columns_test.go | 62 +++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + honeycomb/columns.go | 41 ++++++++++++++++++++++++++ honeycomb/columns_test.go | 37 +++++++++++++++++++++++ 5 files changed, 198 insertions(+) create mode 100644 cmd/columns.go create mode 100644 cmd/columns_test.go create mode 100644 honeycomb/columns.go create mode 100644 honeycomb/columns_test.go diff --git a/cmd/columns.go b/cmd/columns.go new file mode 100644 index 0000000..0cc891e --- /dev/null +++ b/cmd/columns.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +func newColumnsCommand() *cobra.Command { + columnsCmd := &cobra.Command{ + Use: "columns", + Short: "Manage columns in a dataset", + } + + columnsCmd.PersistentFlags().String("dataset", "", "Dataset slug (required)") + _ = columnsCmd.MarkPersistentFlagRequired("dataset") + + columnsCmd.AddCommand(newColumnsListCommand()) + + return columnsCmd +} + +func newColumnsListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List columns in a dataset", + RunE: func(cmd *cobra.Command, args []string) error { + c := newClient(cmd) + dataset, _ := cmd.Flags().GetString("dataset") + + columns, err := c.ListColumns(cmd.Context(), dataset) + if err != nil { + return err + } + + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return json.NewEncoder(cmd.OutOrStdout()).Encode(columns) + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "KEY NAME\tTYPE\tDESCRIPTION\tHIDDEN") + for _, col := range columns { + hidden := "" + if col.Hidden { + hidden = "yes" + } + fmt.Fprintf(w, "%v\t%v\t%v\t%v\n", col.KeyName, col.Type, col.Description, hidden) + } + return w.Flush() + }, + } + cmd.Flags().Bool("json", false, "Output as JSON") + return cmd +} diff --git a/cmd/columns_test.go b/cmd/columns_test.go new file mode 100644 index 0000000..4322d7a --- /dev/null +++ b/cmd/columns_test.go @@ -0,0 +1,62 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/cmd" + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestColumnsListCommand(t *testing.T) { + t.Run("lists columns in a table", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/columns/requests", r.URL.Path) + _ = json.NewEncoder(w).Encode([]honeycomb.Column{ + {ID: "1", KeyName: "duration_ms", Type: "float", Description: "How long it took"}, + {ID: "2", KeyName: "status_code", Type: "integer"}, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"columns", "list", "--dataset", "requests", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + output := buf.String() + is.True(t, contains(output, "duration_ms")) + is.True(t, contains(output, "status_code")) + is.True(t, contains(output, "KEY NAME")) + }) + + t.Run("lists columns as JSON", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]honeycomb.Column{ + {ID: "1", KeyName: "duration_ms", Type: "float"}, + }) + })) + defer server.Close() + + var buf bytes.Buffer + root := cmd.NewRootCommand() + root.SetOut(&buf) + root.SetArgs([]string{"columns", "list", "--dataset", "requests", "--json", "--api-key", "test", "--api-url", server.URL}) + + err := root.Execute() + is.NotError(t, err) + + var columns []honeycomb.Column + is.NotError(t, json.Unmarshal(buf.Bytes(), &columns)) + is.Equal(t, 1, len(columns)) + is.Equal(t, "duration_ms", columns[0].KeyName) + }) +} diff --git a/cmd/root.go b/cmd/root.go index e5469ee..d516755 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newAuthCommand()) root.AddCommand(newDatasetsCommand()) root.AddCommand(newMarkersCommand()) + root.AddCommand(newColumnsCommand()) return root } diff --git a/honeycomb/columns.go b/honeycomb/columns.go new file mode 100644 index 0000000..8a706da --- /dev/null +++ b/honeycomb/columns.go @@ -0,0 +1,41 @@ +package honeycomb + +import ( + "context" + "encoding/json" + "net/http" +) + +// Column in a Honeycomb dataset. +type Column struct { + ID string `json:"id"` + KeyName string `json:"key_name"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Hidden bool `json:"hidden,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + LastWritten string `json:"last_written,omitempty"` +} + +// ListColumns for a dataset. +func (c *Client) ListColumns(ctx context.Context, dataset string) ([]Column, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/1/columns/"+dataset, nil) + if err != nil { + return nil, err + } + + res, err := c.do(req) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + var columns []Column + if err := json.NewDecoder(res.Body).Decode(&columns); err != nil { + return nil, err + } + return columns, nil +} diff --git a/honeycomb/columns_test.go b/honeycomb/columns_test.go new file mode 100644 index 0000000..a18a5f5 --- /dev/null +++ b/honeycomb/columns_test.go @@ -0,0 +1,37 @@ +package honeycomb_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "maragu.dev/is" + + "github.com/maragudk/honeycomb-cli/honeycomb" +) + +func TestClient_ListColumns(t *testing.T) { + t.Run("returns columns for a dataset", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(t, "/1/columns/requests", r.URL.Path) + is.Equal(t, http.MethodGet, r.Method) + + _ = json.NewEncoder(w).Encode([]honeycomb.Column{ + {ID: "1", KeyName: "duration_ms", Type: "float", Description: "Request duration"}, + {ID: "2", KeyName: "status_code", Type: "integer"}, + {ID: "3", KeyName: "trace.trace_id", Type: "string", Hidden: true}, + }) + })) + defer server.Close() + + c := honeycomb.NewClient("test-key", honeycomb.WithBaseURL(server.URL)) + columns, err := c.ListColumns(t.Context(), "requests") + is.NotError(t, err) + is.Equal(t, 3, len(columns)) + is.Equal(t, "duration_ms", columns[0].KeyName) + is.Equal(t, "float", columns[0].Type) + is.Equal(t, "Request duration", columns[0].Description) + is.True(t, columns[2].Hidden) + }) +}