From c8bb8713b27763d72785f60f36efddddc270b60f Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Tue, 24 Feb 2026 15:09:42 +0000 Subject: [PATCH 1/5] fix: display pagination cursors in all list commands Previously, pagination cursors (next/prev) were not displayed to users in CLI output, even though they were available in the API responses. This made it impossible to paginate through large result sets. Changes: - Created pkg/cmd/pagination_output.go with helper functions: - printPaginationInfo() for text output - marshalListResponseWithPagination() for JSON output - Updated all 8 list commands to display pagination info: - event list - request list - attempt list - transformation list - transformation executions - connection list - source list - destination list - Fixed JSON output to always include pagination metadata (previously returned [] for empty results) - Updated test helper functions to handle new JSON response format - Added comprehensive pagination acceptance tests for: - event list (TestEventListPaginationWorkflow) - request list (TestRequestListPaginationWorkflow) - attempt list (TestAttemptListPaginationWorkflow) - Updated TestEventListJSON to verify pagination metadata Fixes #216 --- pkg/cmd/attempt_list.go | 12 ++-- pkg/cmd/connection_list.go | 12 ++-- pkg/cmd/destination_list.go | 11 ++-- pkg/cmd/event_list.go | 12 ++-- pkg/cmd/pagination_output.go | 39 +++++++++++++ pkg/cmd/request_list.go | 12 ++-- pkg/cmd/source_list.go | 11 ++-- pkg/cmd/transformation_executions.go | 11 ++-- pkg/cmd/transformation_list.go | 12 ++-- test/acceptance/attempt_test.go | 58 ++++++++++++++++++++ test/acceptance/event_test.go | 82 ++++++++++++++++++++++++++-- test/acceptance/helpers.go | 45 +++++++++------ test/acceptance/request_test.go | 58 ++++++++++++++++++++ 13 files changed, 304 insertions(+), 71 deletions(-) create mode 100644 pkg/cmd/pagination_output.go diff --git a/pkg/cmd/attempt_list.go b/pkg/cmd/attempt_list.go index 514922a..5554065 100644 --- a/pkg/cmd/attempt_list.go +++ b/pkg/cmd/attempt_list.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "fmt" "os" "strconv" @@ -81,11 +80,7 @@ func (ac *attemptListCmd) runAttemptListCmd(cmd *cobra.Command, args []string) e } if ac.output == "json" { - if len(resp.Models) == 0 { - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) if err != nil { return fmt.Errorf("failed to marshal attempts to json: %w", err) } @@ -106,5 +101,10 @@ func (ac *attemptListCmd) runAttemptListCmd(cmd *cobra.Command, args []string) e } fmt.Printf("%s #%d%s %s\n", color.Green(a.ID), a.AttemptNumber, status, a.Status) } + + // Display pagination info + commandExample := "hookdeck gateway attempt list" + printPaginationInfo(resp.Pagination, commandExample) + return nil } diff --git a/pkg/cmd/connection_list.go b/pkg/cmd/connection_list.go index dca1ade..f2e19d9 100644 --- a/pkg/cmd/connection_list.go +++ b/pkg/cmd/connection_list.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "fmt" "os" "strconv" @@ -113,12 +112,7 @@ func (cc *connectionListCmd) runConnectionListCmd(cmd *cobra.Command, args []str } if cc.output == "json" { - if len(response.Models) == 0 { - // Print an empty JSON array - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(response.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(response.Models, response.Pagination) if err != nil { return fmt.Errorf("failed to marshal connections to json: %w", err) } @@ -175,5 +169,9 @@ func (cc *connectionListCmd) runConnectionListCmd(cmd *cobra.Command, args []str fmt.Println() } + // Display pagination info + commandExample := "hookdeck gateway connection list" + printPaginationInfo(response.Pagination, commandExample) + return nil } diff --git a/pkg/cmd/destination_list.go b/pkg/cmd/destination_list.go index 57a4997..f6449fc 100644 --- a/pkg/cmd/destination_list.go +++ b/pkg/cmd/destination_list.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "fmt" "os" "strconv" @@ -77,11 +76,7 @@ func (dc *destinationListCmd) runDestinationListCmd(cmd *cobra.Command, args []s } if dc.output == "json" { - if len(resp.Models) == 0 { - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) if err != nil { return fmt.Errorf("failed to marshal destinations to json: %w", err) } @@ -111,5 +106,9 @@ func (dc *destinationListCmd) runDestinationListCmd(cmd *cobra.Command, args []s fmt.Println() } + // Display pagination info + commandExample := "hookdeck gateway destination list" + printPaginationInfo(resp.Pagination, commandExample) + return nil } diff --git a/pkg/cmd/event_list.go b/pkg/cmd/event_list.go index 6833860..666b3bd 100644 --- a/pkg/cmd/event_list.go +++ b/pkg/cmd/event_list.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "fmt" "os" "strconv" @@ -177,11 +176,7 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error } if ec.output == "json" { - if len(resp.Models) == 0 { - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) if err != nil { return fmt.Errorf("failed to marshal events to json: %w", err) } @@ -198,5 +193,10 @@ func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error for _, e := range resp.Models { fmt.Printf("%s %s %s\n", color.Green(e.ID), e.Status, e.WebhookID) } + + // Display pagination info + commandExample := "hookdeck gateway event list" + printPaginationInfo(resp.Pagination, commandExample) + return nil } diff --git a/pkg/cmd/pagination_output.go b/pkg/cmd/pagination_output.go new file mode 100644 index 0000000..a1971cc --- /dev/null +++ b/pkg/cmd/pagination_output.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// printPaginationInfo displays pagination cursors for text output +func printPaginationInfo(pagination hookdeck.PaginationResponse, commandExample string) { + if pagination.Next == nil && pagination.Prev == nil { + return + } + + fmt.Println() + fmt.Println("Pagination:") + if pagination.Prev != nil { + fmt.Printf(" Prev: %s\n", *pagination.Prev) + } + if pagination.Next != nil { + fmt.Printf(" Next: %s\n", *pagination.Next) + } + + if pagination.Next != nil { + fmt.Println() + fmt.Println("To get the next page:") + fmt.Printf(" %s --next %s\n", commandExample, *pagination.Next) + } +} + +// marshalListResponseWithPagination marshals a list response including pagination +func marshalListResponseWithPagination(models interface{}, pagination hookdeck.PaginationResponse) ([]byte, error) { + response := map[string]interface{}{ + "models": models, + "pagination": pagination, + } + return json.MarshalIndent(response, "", " ") +} diff --git a/pkg/cmd/request_list.go b/pkg/cmd/request_list.go index 32797ad..1fdd690 100644 --- a/pkg/cmd/request_list.go +++ b/pkg/cmd/request_list.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "fmt" "os" "strconv" @@ -141,11 +140,7 @@ func (rc *requestListCmd) runRequestListCmd(cmd *cobra.Command, args []string) e } if rc.output == "json" { - if len(resp.Models) == 0 { - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) if err != nil { return fmt.Errorf("failed to marshal requests to json: %w", err) } @@ -162,5 +157,10 @@ func (rc *requestListCmd) runRequestListCmd(cmd *cobra.Command, args []string) e for _, r := range resp.Models { fmt.Printf("%s %s (events: %d)\n", color.Green(r.ID), r.SourceID, r.EventsCount) } + + // Display pagination info + commandExample := "hookdeck gateway request list" + printPaginationInfo(resp.Pagination, commandExample) + return nil } diff --git a/pkg/cmd/source_list.go b/pkg/cmd/source_list.go index d4bd34e..72440a2 100644 --- a/pkg/cmd/source_list.go +++ b/pkg/cmd/source_list.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "fmt" "os" "strconv" @@ -77,11 +76,7 @@ func (sc *sourceListCmd) runSourceListCmd(cmd *cobra.Command, args []string) err } if sc.output == "json" { - if len(resp.Models) == 0 { - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) if err != nil { return fmt.Errorf("failed to marshal sources to json: %w", err) } @@ -109,5 +104,9 @@ func (sc *sourceListCmd) runSourceListCmd(cmd *cobra.Command, args []string) err fmt.Println() } + // Display pagination info + commandExample := "hookdeck gateway source list" + printPaginationInfo(resp.Pagination, commandExample) + return nil } diff --git a/pkg/cmd/transformation_executions.go b/pkg/cmd/transformation_executions.go index ff4424c..f61af14 100644 --- a/pkg/cmd/transformation_executions.go +++ b/pkg/cmd/transformation_executions.go @@ -112,11 +112,7 @@ func (tc *transformationExecutionsListCmd) run(cmd *cobra.Command, args []string } if tc.output == "json" { - if len(resp.Models) == 0 { - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) if err != nil { return fmt.Errorf("failed to marshal executions to json: %w", err) } @@ -133,6 +129,11 @@ func (tc *transformationExecutionsListCmd) run(cmd *cobra.Command, args []string for _, e := range resp.Models { fmt.Printf("%s %s\n", color.Green(e.ID), e.CreatedAt.Format("2006-01-02 15:04:05")) } + + // Display pagination info + commandExample := "hookdeck gateway transformation executions list" + printPaginationInfo(resp.Pagination, commandExample) + return nil } diff --git a/pkg/cmd/transformation_list.go b/pkg/cmd/transformation_list.go index ae72821..f07a3a4 100644 --- a/pkg/cmd/transformation_list.go +++ b/pkg/cmd/transformation_list.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "fmt" "os" "strconv" @@ -88,11 +87,7 @@ func (tc *transformationListCmd) runTransformationListCmd(cmd *cobra.Command, ar } if tc.output == "json" { - if len(resp.Models) == 0 { - fmt.Println("[]") - return nil - } - jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + jsonBytes, err := marshalListResponseWithPagination(resp.Models, resp.Pagination) if err != nil { return fmt.Errorf("failed to marshal transformations to json: %w", err) } @@ -109,5 +104,10 @@ func (tc *transformationListCmd) runTransformationListCmd(cmd *cobra.Command, ar for _, t := range resp.Models { fmt.Printf("%s %s\n", color.Green(t.Name), t.ID) } + + // Display pagination info + commandExample := "hookdeck gateway transformation list" + printPaginationInfo(resp.Pagination, commandExample) + return nil } diff --git a/test/acceptance/attempt_test.go b/test/acceptance/attempt_test.go index 2b47e55..4363997 100644 --- a/test/acceptance/attempt_test.go +++ b/test/acceptance/attempt_test.go @@ -2,8 +2,10 @@ package acceptance import ( "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAttemptList(t *testing.T) { @@ -105,3 +107,59 @@ func TestAttemptListWithPrev(t *testing.T) { assert.Contains(t, err.Error(), "exit status") } } + +func TestAttemptListPaginationWorkflow(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + // Retry the event multiple times to create multiple attempts + for i := 0; i < 3; i++ { + cli.RunExpectSuccess("gateway", "event", "retry", eventID) + time.Sleep(2 * time.Second) // Wait for retry to be processed + } + + // Poll for attempts to ensure we have multiple + attempts := pollForAttemptsByEventID(t, cli, eventID) + require.GreaterOrEqual(t, len(attempts), 2, "Need at least 2 attempts for pagination test") + + // Test 1: JSON output includes pagination metadata + type AttemptListResponse struct { + Models []Attempt `json:"models"` + Pagination map[string]interface{} `json:"pagination"` + } + var firstPageResp AttemptListResponse + require.NoError(t, cli.RunJSON(&firstPageResp, "gateway", "attempt", "list", "--event-id", eventID, "--limit", "2")) + assert.NotEmpty(t, firstPageResp.Models, "First page should have attempts") + assert.NotNil(t, firstPageResp.Pagination, "JSON response should include pagination metadata") + assert.Contains(t, firstPageResp.Pagination, "limit") + assert.Equal(t, float64(2), firstPageResp.Pagination["limit"]) + + // Test 2: Text output includes pagination info when next cursor exists + if len(firstPageResp.Models) == 2 && firstPageResp.Pagination["next"] != nil { + stdout := cli.RunExpectSuccess("gateway", "attempt", "list", "--event-id", eventID, "--limit", "2") + assert.Contains(t, stdout, "Pagination:") + assert.Contains(t, stdout, "Next:") + assert.Contains(t, stdout, "To get the next page:") + assert.Contains(t, stdout, "--next") + + // Test 3: Use next cursor to get second page + nextCursor := firstPageResp.Pagination["next"].(string) + var secondPageResp AttemptListResponse + require.NoError(t, cli.RunJSON(&secondPageResp, "gateway", "attempt", "list", "--event-id", eventID, "--limit", "2", "--next", nextCursor)) + assert.NotEmpty(t, secondPageResp.Models, "Second page should have attempts") + + // Verify pages contain different attempts + firstPageIDs := make(map[string]bool) + for _, a := range firstPageResp.Models { + firstPageIDs[a.ID] = true + } + for _, a := range secondPageResp.Models { + assert.False(t, firstPageIDs[a.ID], "Second page should not contain attempts from first page") + } + } +} diff --git a/test/acceptance/event_test.go b/test/acceptance/event_test.go index abbc70f..9d3ba3a 100644 --- a/test/acceptance/event_test.go +++ b/test/acceptance/event_test.go @@ -2,6 +2,7 @@ package acceptance import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -93,11 +94,16 @@ func TestEventListJSON(t *testing.T) { connID, _ := createConnectionAndTriggerEvent(t, cli) t.Cleanup(func() { deleteConnection(t, cli, connID) }) - var events []Event - require.NoError(t, cli.RunJSON(&events, "gateway", "event", "list", "--connection-id", connID, "--limit", "5")) - assert.NotEmpty(t, events) - assert.NotEmpty(t, events[0].ID) - assert.NotEmpty(t, events[0].Status) + type EventListResponse struct { + Models []Event `json:"models"` + Pagination map[string]interface{} `json:"pagination"` + } + var resp EventListResponse + require.NoError(t, cli.RunJSON(&resp, "gateway", "event", "list", "--connection-id", connID, "--limit", "5")) + assert.NotEmpty(t, resp.Models) + assert.NotEmpty(t, resp.Models[0].ID) + assert.NotEmpty(t, resp.Models[0].Status) + assert.NotNil(t, resp.Pagination) } func TestEventRawBody(t *testing.T) { @@ -313,3 +319,69 @@ func TestEventListWithPrev(t *testing.T) { assert.Contains(t, err.Error(), "exit status") } } + +func TestEventListPaginationWorkflow(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + // Trigger multiple events to ensure we have enough for pagination + triggerEvent(t, cli, connID) + triggerEvent(t, cli, connID) + triggerEvent(t, cli, connID) + time.Sleep(2 * time.Second) // Wait for events to be processed + + // Test 1: JSON output includes pagination metadata + type EventListResponse struct { + Models []Event `json:"models"` + Pagination map[string]interface{} `json:"pagination"` + } + var firstPageResp EventListResponse + require.NoError(t, cli.RunJSON(&firstPageResp, "gateway", "event", "list", "--connection-id", connID, "--limit", "2")) + assert.NotEmpty(t, firstPageResp.Models, "First page should have events") + assert.NotNil(t, firstPageResp.Pagination, "JSON response should include pagination metadata") + assert.Contains(t, firstPageResp.Pagination, "limit") + assert.Equal(t, float64(2), firstPageResp.Pagination["limit"]) + + // Test 2: Text output includes pagination info when next cursor exists + if len(firstPageResp.Models) == 2 && firstPageResp.Pagination["next"] != nil { + stdout := cli.RunExpectSuccess("gateway", "event", "list", "--connection-id", connID, "--limit", "2") + assert.Contains(t, stdout, "Pagination:") + assert.Contains(t, stdout, "Next:") + assert.Contains(t, stdout, "To get the next page:") + assert.Contains(t, stdout, "--next") + + // Test 3: Use next cursor to get second page + nextCursor := firstPageResp.Pagination["next"].(string) + var secondPageResp EventListResponse + require.NoError(t, cli.RunJSON(&secondPageResp, "gateway", "event", "list", "--connection-id", connID, "--limit", "2", "--next", nextCursor)) + assert.NotEmpty(t, secondPageResp.Models, "Second page should have events") + + // Verify pages contain different events + firstPageIDs := make(map[string]bool) + for _, e := range firstPageResp.Models { + firstPageIDs[e.ID] = true + } + for _, e := range secondPageResp.Models { + assert.False(t, firstPageIDs[e.ID], "Second page should not contain events from first page") + } + } +} + +// triggerEvent is a helper function to trigger an additional event on an existing connection +func triggerEvent(t *testing.T, cli *CLIRunner, connID string) { + t.Helper() + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + require.NotEmpty(t, conn.Source.ID, "connection source ID") + + var src Source + require.NoError(t, cli.RunJSON(&src, "gateway", "source", "get", conn.Source.ID)) + require.NotEmpty(t, src.URL, "source URL") + + triggerTestEvent(t, src.URL) +} diff --git a/test/acceptance/helpers.go b/test/acceptance/helpers.go index 3af5815..5ed647b 100644 --- a/test/acceptance/helpers.go +++ b/test/acceptance/helpers.go @@ -478,16 +478,19 @@ func createConnectionAndTriggerEvent(t *testing.T, cli *CLIRunner) (connID, even triggerTestEvent(t, src.URL) // Poll for event to appear (API may take a few seconds) - var events []Event + type EventListResponse struct { + Models []Event `json:"models"` + } for i := 0; i < 10; i++ { time.Sleep(2 * time.Second) - require.NoError(t, cli.RunJSON(&events, "gateway", "event", "list", "--connection-id", connID, "--limit", "1")) - if len(events) > 0 { - return connID, events[0].ID + var resp EventListResponse + require.NoError(t, cli.RunJSON(&resp, "gateway", "event", "list", "--connection-id", connID, "--limit", "1")) + if len(resp.Models) > 0 { + return connID, resp.Models[0].ID } } - require.NotEmpty(t, events, "expected at least one event after trigger (waited ~20s)") - return connID, events[0].ID + require.Fail(t, "expected at least one event after trigger (waited ~20s)") + return "", "" } // pollForRequestsBySourceID polls gateway request list by source ID until at least one request @@ -495,16 +498,19 @@ func createConnectionAndTriggerEvent(t *testing.T, cli *CLIRunner) (connID, even // requires at least one request; fails the test if none appear (no skip). func pollForRequestsBySourceID(t *testing.T, cli *CLIRunner, sourceID string) []Request { t.Helper() - var requests []Request + type RequestListResponse struct { + Models []Request `json:"models"` + } for i := 0; i < 10; i++ { time.Sleep(2 * time.Second) - require.NoError(t, cli.RunJSON(&requests, "gateway", "request", "list", "--source-id", sourceID, "--limit", "5")) - if len(requests) > 0 { - return requests + var resp RequestListResponse + require.NoError(t, cli.RunJSON(&resp, "gateway", "request", "list", "--source-id", sourceID, "--limit", "5")) + if len(resp.Models) > 0 { + return resp.Models } } - require.NotEmpty(t, requests, "expected at least one request after trigger (waited ~20s)") - return requests + require.Fail(t, "expected at least one request after trigger (waited ~20s)") + return nil } // pollForAttemptsByEventID polls gateway attempt list by event ID until at least one attempt @@ -512,16 +518,19 @@ func pollForRequestsBySourceID(t *testing.T, cli *CLIRunner, sourceID string) [] // when the test requires attempts; attempt creation may lag behind event creation. func pollForAttemptsByEventID(t *testing.T, cli *CLIRunner, eventID string) []Attempt { t.Helper() - var attempts []Attempt + type AttemptListResponse struct { + Models []Attempt `json:"models"` + } for i := 0; i < 10; i++ { time.Sleep(2 * time.Second) - require.NoError(t, cli.RunJSON(&attempts, "gateway", "attempt", "list", "--event-id", eventID, "--limit", "5")) - if len(attempts) > 0 { - return attempts + var resp AttemptListResponse + require.NoError(t, cli.RunJSON(&resp, "gateway", "attempt", "list", "--event-id", eventID, "--limit", "5")) + if len(resp.Models) > 0 { + return resp.Models } } - require.NotEmpty(t, attempts, "expected at least one attempt after trigger (waited ~20s)") - return attempts + require.Fail(t, "expected at least one attempt after trigger (waited ~20s)") + return nil } // assertContains checks if a string contains a substring diff --git a/test/acceptance/request_test.go b/test/acceptance/request_test.go index 41e0323..cb25d38 100644 --- a/test/acceptance/request_test.go +++ b/test/acceptance/request_test.go @@ -2,6 +2,7 @@ package acceptance import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -334,3 +335,60 @@ func TestRequestIgnoredEventsWithPrev(t *testing.T) { assert.Contains(t, err.Error(), "exit status") } } + +func TestRequestListPaginationWorkflow(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + // Get connection to find source ID + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + require.NotEmpty(t, conn.Source.ID, "connection source ID") + + // Trigger multiple requests to ensure we have enough for pagination + triggerEvent(t, cli, connID) + triggerEvent(t, cli, connID) + triggerEvent(t, cli, connID) + time.Sleep(2 * time.Second) // Wait for requests to be processed + + // Test 1: JSON output includes pagination metadata + type RequestListResponse struct { + Models []Request `json:"models"` + Pagination map[string]interface{} `json:"pagination"` + } + var firstPageResp RequestListResponse + require.NoError(t, cli.RunJSON(&firstPageResp, "gateway", "request", "list", "--source-id", conn.Source.ID, "--limit", "2")) + assert.NotEmpty(t, firstPageResp.Models, "First page should have requests") + assert.NotNil(t, firstPageResp.Pagination, "JSON response should include pagination metadata") + assert.Contains(t, firstPageResp.Pagination, "limit") + assert.Equal(t, float64(2), firstPageResp.Pagination["limit"]) + + // Test 2: Text output includes pagination info when next cursor exists + if len(firstPageResp.Models) == 2 && firstPageResp.Pagination["next"] != nil { + stdout := cli.RunExpectSuccess("gateway", "request", "list", "--source-id", conn.Source.ID, "--limit", "2") + assert.Contains(t, stdout, "Pagination:") + assert.Contains(t, stdout, "Next:") + assert.Contains(t, stdout, "To get the next page:") + assert.Contains(t, stdout, "--next") + + // Test 3: Use next cursor to get second page + nextCursor := firstPageResp.Pagination["next"].(string) + var secondPageResp RequestListResponse + require.NoError(t, cli.RunJSON(&secondPageResp, "gateway", "request", "list", "--source-id", conn.Source.ID, "--limit", "2", "--next", nextCursor)) + assert.NotEmpty(t, secondPageResp.Models, "Second page should have requests") + + // Verify pages contain different requests + firstPageIDs := make(map[string]bool) + for _, r := range firstPageResp.Models { + firstPageIDs[r.ID] = true + } + for _, r := range secondPageResp.Models { + assert.False(t, firstPageIDs[r.ID], "Second page should not contain requests from first page") + } + } +} From 4c3d61a97d7032ef49af460ef29c1603e9485682 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 25 Feb 2026 23:21:52 +0000 Subject: [PATCH 2/5] feat(gateway): add metrics query commands (Phase 5a-E) - Add pkg/hookdeck/metrics.go: 7 API methods, MetricsQueryParams, response parsing - Add gateway metrics command group with shared flags (--start, --end, --granularity, --measures, --dimensions, filters) - Add 7 subcommands: events, requests, attempts, queue-depth, pending, events-by-issue, transformations - events-by-issue takes as positional argument; other commands use optional filter flags - Add test/acceptance/metrics_test.go: help, baseline, common flags, validation, JSON output (~27 tests) - Update REFERENCE.md with metrics use-case table and examples Implements Phase 5a-E metrics plan. API requires date range and measures; events-by-issue requires issue ID. Made-with: Cursor --- REFERENCE.md | 19 ++ pkg/cmd/gateway.go | 1 + pkg/cmd/metrics.go | 155 +++++++++++++++++ pkg/cmd/metrics_attempts.go | 40 +++++ pkg/cmd/metrics_events.go | 40 +++++ pkg/cmd/metrics_events_by_issue.go | 41 +++++ pkg/cmd/metrics_pending.go | 38 ++++ pkg/cmd/metrics_queue_depth.go | 41 +++++ pkg/cmd/metrics_requests.go | 40 +++++ pkg/cmd/metrics_transformations.go | 40 +++++ pkg/hookdeck/metrics.go | 134 ++++++++++++++ test/acceptance/metrics_test.go | 270 +++++++++++++++++++++++++++++ 12 files changed, 859 insertions(+) create mode 100644 pkg/cmd/metrics.go create mode 100644 pkg/cmd/metrics_attempts.go create mode 100644 pkg/cmd/metrics_events.go create mode 100644 pkg/cmd/metrics_events_by_issue.go create mode 100644 pkg/cmd/metrics_pending.go create mode 100644 pkg/cmd/metrics_queue_depth.go create mode 100644 pkg/cmd/metrics_requests.go create mode 100644 pkg/cmd/metrics_transformations.go create mode 100644 pkg/hookdeck/metrics.go create mode 100644 test/acceptance/metrics_test.go diff --git a/REFERENCE.md b/REFERENCE.md index 1e9014d..f02f9aa 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -19,6 +19,7 @@ The Hookdeck CLI provides comprehensive webhook infrastructure management includ - [Events](#events) - [Requests](#requests) - [Attempts](#attempts) +- [Metrics](#metrics) - [Utilities](#utilities) ## Global Options @@ -1782,6 +1783,24 @@ hookdeck gateway attempt get [flags] hookdeck gateway attempt get atm_abc123 ``` +## Metrics + +Query Event Gateway metrics (events, requests, attempts, queue depth, pending events, events by issue, transformations). All metrics commands require `--start` and `--end` (ISO 8601 date-time). + +**Use cases and examples:** + +| Use case | Example command | +|----------|-----------------| +| Event volume and failure rate over time | `hookdeck gateway metrics events --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --granularity 1d --measures count,failed_count,error_rate` | +| Request acceptance vs rejection | `hookdeck gateway metrics requests --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count,accepted_count,rejected_count` | +| Delivery latency (attempts) | `hookdeck gateway metrics attempts --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures response_latency_avg,response_latency_p95` | +| Queue backlog per destination | `hookdeck gateway metrics queue-depth --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures max_depth,max_age --destination-id dest_xxx` | +| Pending events over time | `hookdeck gateway metrics pending --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --granularity 1h --measures count` | +| Events grouped by issue (debugging) | `hookdeck gateway metrics events-by-issue iss_xxx --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count` | +| Transformation errors | `hookdeck gateway metrics transformations --start 2026-02-01T00:00:00Z --end 2026-02-25T00:00:00Z --measures count,failed_count,error_rate` | + +**Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--source-id`, `--destination-id`, `--connection-id`, `--status`, `--output` (json). + ## Utilities diff --git a/pkg/cmd/gateway.go b/pkg/cmd/gateway.go index 3e5b9a0..51c1e7e 100644 --- a/pkg/cmd/gateway.go +++ b/pkg/cmd/gateway.go @@ -39,6 +39,7 @@ The gateway command group provides full access to all Event Gateway resources.`, addEventCmdTo(g.cmd) addRequestCmdTo(g.cmd) addAttemptCmdTo(g.cmd) + addMetricsCmdTo(g.cmd) return g } diff --git a/pkg/cmd/metrics.go b/pkg/cmd/metrics.go new file mode 100644 index 0000000..9e2a78e --- /dev/null +++ b/pkg/cmd/metrics.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// printMetricsResponse prints data as JSON or a human-readable table. +func printMetricsResponse(data hookdeck.MetricsResponse, output string) error { + if output == "json" { + bytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metrics: %w", err) + } + fmt.Println(string(bytes)) + return nil + } + if len(data) == 0 { + fmt.Println("No data points.") + return nil + } + for i, pt := range data { + tb := "" + if pt.TimeBucket != nil { + tb = *pt.TimeBucket + } + fmt.Printf("time_bucket: %s\n", tb) + if len(pt.Dimensions) > 0 { + for k, v := range pt.Dimensions { + fmt.Printf(" %s: %v\n", k, v) + } + } + if len(pt.Metrics) > 0 { + for k, v := range pt.Metrics { + fmt.Printf(" %s: %v\n", k, v) + } + } + if i < len(data)-1 { + fmt.Println("---") + } + } + return nil +} + +const granularityHelp = `Time bucket size. Format: (e.g. 1h, 5m, 1d). +Units: s (seconds), m (minutes), h (hours), d (days), w (weeks), M (months).` + +// metricsCommonFlags holds the common flags for all metrics subcommands. +// Used by addMetricsCommonFlags and to build hookdeck.MetricsQueryParams. +type metricsCommonFlags struct { + start string + end string + granularity string + measures string + dimensions string + sourceID string + destinationID string + connectionID string + status string + issueID string + output string +} + +// addMetricsCommonFlags adds common metrics flags to cmd and binds them to f. +// For subcommands that take a required resource id as an argument (e.g. events-by-issue ), +// pass skipIssueID true so --issue-id is not added as a flag. +func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags) { + addMetricsCommonFlagsEx(cmd, f, false) +} + +func addMetricsCommonFlagsEx(cmd *cobra.Command, f *metricsCommonFlags, skipIssueID bool) { + cmd.Flags().StringVar(&f.start, "start", "", "Start of time range (ISO 8601 date-time, required)") + cmd.Flags().StringVar(&f.end, "end", "", "End of time range (ISO 8601 date-time, required)") + cmd.Flags().StringVar(&f.granularity, "granularity", "", granularityHelp) + cmd.Flags().StringVar(&f.measures, "measures", "", "Comma-separated list of measures to return") + cmd.Flags().StringVar(&f.dimensions, "dimensions", "", "Comma-separated list of dimensions") + cmd.Flags().StringVar(&f.sourceID, "source-id", "", "Filter by source ID") + cmd.Flags().StringVar(&f.destinationID, "destination-id", "", "Filter by destination ID") + cmd.Flags().StringVar(&f.connectionID, "connection-id", "", "Filter by connection ID") + cmd.Flags().StringVar(&f.status, "status", "", "Filter by status (e.g. SUCCESSFUL, FAILED)") + if !skipIssueID { + cmd.Flags().StringVar(&f.issueID, "issue-id", "", "Filter by issue ID") + } + cmd.Flags().StringVar(&f.output, "output", "", "Output format (json)") + _ = cmd.MarkFlagRequired("start") + _ = cmd.MarkFlagRequired("end") +} + +// metricsParamsFromFlags builds hookdeck.MetricsQueryParams from common flags. +// Measures and dimensions are split from comma-separated strings. +func metricsParamsFromFlags(f *metricsCommonFlags) hookdeck.MetricsQueryParams { + var measures, dimensions []string + if f.measures != "" { + for _, s := range strings.Split(f.measures, ",") { + if t := strings.TrimSpace(s); t != "" { + measures = append(measures, t) + } + } + } + if f.dimensions != "" { + for _, s := range strings.Split(f.dimensions, ",") { + if t := strings.TrimSpace(s); t != "" { + dimensions = append(dimensions, t) + } + } + } + return hookdeck.MetricsQueryParams{ + Start: f.start, + End: f.end, + Granularity: f.granularity, + Measures: measures, + Dimensions: dimensions, + SourceID: f.sourceID, + DestinationID: f.destinationID, + ConnectionID: f.connectionID, + Status: f.status, + IssueID: f.issueID, + } +} + +type metricsCmd struct { + cmd *cobra.Command +} + +func newMetricsCmd() *metricsCmd { + mc := &metricsCmd{} + + mc.cmd = &cobra.Command{ + Use: "metrics", + Args: validators.NoArgs, + Short: ShortBeta("Query Event Gateway metrics"), + Long: LongBeta(`Query metrics for events, requests, attempts, queue depth, pending events, events by issue, and transformations. +Requires --start and --end (ISO 8601 date-time). Use subcommands to choose the metric type.`), + } + + mc.cmd.AddCommand(newMetricsEventsCmd().cmd) + mc.cmd.AddCommand(newMetricsRequestsCmd().cmd) + mc.cmd.AddCommand(newMetricsAttemptsCmd().cmd) + mc.cmd.AddCommand(newMetricsQueueDepthCmd().cmd) + mc.cmd.AddCommand(newMetricsPendingCmd().cmd) + mc.cmd.AddCommand(newMetricsEventsByIssueCmd().cmd) + mc.cmd.AddCommand(newMetricsTransformationsCmd().cmd) + + return mc +} + +func addMetricsCmdTo(parent *cobra.Command) { + parent.AddCommand(newMetricsCmd().cmd) +} diff --git a/pkg/cmd/metrics_attempts.go b/pkg/cmd/metrics_attempts.go new file mode 100644 index 0000000..1d24ee1 --- /dev/null +++ b/pkg/cmd/metrics_attempts.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +const metricsAttemptsMeasures = "count, successful_count, failed_count, delivered_count, error_rate, response_latency_avg, response_latency_max, response_latency_p95, response_latency_p99, delivery_latency_avg" + +type metricsAttemptsCmd struct { + cmd *cobra.Command + flags metricsCommonFlags +} + +func newMetricsAttemptsCmd() *metricsAttemptsCmd { + c := &metricsAttemptsCmd{} + c.cmd = &cobra.Command{ + Use: "attempts", + Args: cobra.NoArgs, + Short: "Query attempt metrics", + Long: `Query metrics for delivery attempts (latency, success/failure). Measures: ` + metricsAttemptsMeasures + `.`, + RunE: c.runE, + } + addMetricsCommonFlags(c.cmd, &c.flags) + return c +} + +func (c *metricsAttemptsCmd) runE(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + params := metricsParamsFromFlags(&c.flags) + data, err := Config.GetAPIClient().QueryAttemptMetrics(context.Background(), params) + if err != nil { + return fmt.Errorf("query attempt metrics: %w", err) + } + return printMetricsResponse(data, c.flags.output) +} diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go new file mode 100644 index 0000000..1f30169 --- /dev/null +++ b/pkg/cmd/metrics_events.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +const metricsEventsMeasures = "count, successful_count, failed_count, scheduled_count, paused_count, error_rate, avg_attempts, scheduled_retry_count" + +type metricsEventsCmd struct { + cmd *cobra.Command + flags metricsCommonFlags +} + +func newMetricsEventsCmd() *metricsEventsCmd { + c := &metricsEventsCmd{} + c.cmd = &cobra.Command{ + Use: "events", + Args: cobra.NoArgs, + Short: "Query event metrics", + Long: `Query metrics for events (volume, success/failure counts, error rate, etc.). Measures: ` + metricsEventsMeasures + `.`, + RunE: c.runE, + } + addMetricsCommonFlags(c.cmd, &c.flags) + return c +} + +func (c *metricsEventsCmd) runE(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + params := metricsParamsFromFlags(&c.flags) + data, err := Config.GetAPIClient().QueryEventMetrics(context.Background(), params) + if err != nil { + return fmt.Errorf("query event metrics: %w", err) + } + return printMetricsResponse(data, c.flags.output) +} diff --git a/pkg/cmd/metrics_events_by_issue.go b/pkg/cmd/metrics_events_by_issue.go new file mode 100644 index 0000000..54d1acc --- /dev/null +++ b/pkg/cmd/metrics_events_by_issue.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type metricsEventsByIssueCmd struct { + cmd *cobra.Command + flags metricsCommonFlags +} + +func newMetricsEventsByIssueCmd() *metricsEventsByIssueCmd { + c := &metricsEventsByIssueCmd{} + c.cmd = &cobra.Command{ + Use: "events-by-issue ", + Args: validators.ExactArgs(1), + Short: "Query events grouped by issue", + Long: `Query metrics for events grouped by issue (for debugging). Requires issue ID as argument.`, + RunE: c.runE, + } + addMetricsCommonFlagsEx(c.cmd, &c.flags, true) + return c +} + +func (c *metricsEventsByIssueCmd) runE(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + params := metricsParamsFromFlags(&c.flags) + params.IssueID = args[0] + data, err := Config.GetAPIClient().QueryEventsByIssue(context.Background(), params) + if err != nil { + return fmt.Errorf("query events by issue: %w", err) + } + return printMetricsResponse(data, c.flags.output) +} diff --git a/pkg/cmd/metrics_pending.go b/pkg/cmd/metrics_pending.go new file mode 100644 index 0000000..5e7ec21 --- /dev/null +++ b/pkg/cmd/metrics_pending.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +type metricsPendingCmd struct { + cmd *cobra.Command + flags metricsCommonFlags +} + +func newMetricsPendingCmd() *metricsPendingCmd { + c := &metricsPendingCmd{} + c.cmd = &cobra.Command{ + Use: "pending", + Args: cobra.NoArgs, + Short: "Query events pending timeseries", + Long: `Query events pending over time (timeseries). Measures: count.`, + RunE: c.runE, + } + addMetricsCommonFlags(c.cmd, &c.flags) + return c +} + +func (c *metricsPendingCmd) runE(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + params := metricsParamsFromFlags(&c.flags) + data, err := Config.GetAPIClient().QueryEventsPendingTimeseries(context.Background(), params) + if err != nil { + return fmt.Errorf("query events pending: %w", err) + } + return printMetricsResponse(data, c.flags.output) +} diff --git a/pkg/cmd/metrics_queue_depth.go b/pkg/cmd/metrics_queue_depth.go new file mode 100644 index 0000000..acd1e79 --- /dev/null +++ b/pkg/cmd/metrics_queue_depth.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +const metricsQueueDepthMeasures = "max_depth, max_age" +const metricsQueueDepthDimensions = "destination_id" + +type metricsQueueDepthCmd struct { + cmd *cobra.Command + flags metricsCommonFlags +} + +func newMetricsQueueDepthCmd() *metricsQueueDepthCmd { + c := &metricsQueueDepthCmd{} + c.cmd = &cobra.Command{ + Use: "queue-depth", + Args: cobra.NoArgs, + Short: "Query queue depth metrics", + Long: `Query queue depth metrics. Measures: ` + metricsQueueDepthMeasures + `. Dimensions: ` + metricsQueueDepthDimensions + `.`, + RunE: c.runE, + } + addMetricsCommonFlags(c.cmd, &c.flags) + return c +} + +func (c *metricsQueueDepthCmd) runE(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + params := metricsParamsFromFlags(&c.flags) + data, err := Config.GetAPIClient().QueryQueueDepth(context.Background(), params) + if err != nil { + return fmt.Errorf("query queue depth: %w", err) + } + return printMetricsResponse(data, c.flags.output) +} diff --git a/pkg/cmd/metrics_requests.go b/pkg/cmd/metrics_requests.go new file mode 100644 index 0000000..d2cf352 --- /dev/null +++ b/pkg/cmd/metrics_requests.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +const metricsRequestsMeasures = "count, accepted_count, rejected_count, discarded_count, avg_events_per_request, avg_ignored_per_request" + +type metricsRequestsCmd struct { + cmd *cobra.Command + flags metricsCommonFlags +} + +func newMetricsRequestsCmd() *metricsRequestsCmd { + c := &metricsRequestsCmd{} + c.cmd = &cobra.Command{ + Use: "requests", + Args: cobra.NoArgs, + Short: "Query request metrics", + Long: `Query metrics for requests (acceptance, rejection, etc.). Measures: ` + metricsRequestsMeasures + `.`, + RunE: c.runE, + } + addMetricsCommonFlags(c.cmd, &c.flags) + return c +} + +func (c *metricsRequestsCmd) runE(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + params := metricsParamsFromFlags(&c.flags) + data, err := Config.GetAPIClient().QueryRequestMetrics(context.Background(), params) + if err != nil { + return fmt.Errorf("query request metrics: %w", err) + } + return printMetricsResponse(data, c.flags.output) +} diff --git a/pkg/cmd/metrics_transformations.go b/pkg/cmd/metrics_transformations.go new file mode 100644 index 0000000..be9bf47 --- /dev/null +++ b/pkg/cmd/metrics_transformations.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" +) + +const metricsTransformationsMeasures = "count, successful_count, failed_count, error_rate, error_count, warn_count, info_count, debug_count" + +type metricsTransformationsCmd struct { + cmd *cobra.Command + flags metricsCommonFlags +} + +func newMetricsTransformationsCmd() *metricsTransformationsCmd { + c := &metricsTransformationsCmd{} + c.cmd = &cobra.Command{ + Use: "transformations", + Args: cobra.NoArgs, + Short: "Query transformation metrics", + Long: `Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`, + RunE: c.runE, + } + addMetricsCommonFlags(c.cmd, &c.flags) + return c +} + +func (c *metricsTransformationsCmd) runE(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + params := metricsParamsFromFlags(&c.flags) + data, err := Config.GetAPIClient().QueryTransformationMetrics(context.Background(), params) + if err != nil { + return fmt.Errorf("query transformation metrics: %w", err) + } + return printMetricsResponse(data, c.flags.output) +} diff --git a/pkg/hookdeck/metrics.go b/pkg/hookdeck/metrics.go new file mode 100644 index 0000000..9313332 --- /dev/null +++ b/pkg/hookdeck/metrics.go @@ -0,0 +1,134 @@ +package hookdeck + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/url" +) + +// MetricDataPoint is a single metric data point with time bucket, dimensions, and metrics. +// All metrics endpoints return an array of MetricDataPoint. +type MetricDataPoint struct { + TimeBucket *string `json:"time_bucket,omitempty"` + Dimensions map[string]interface{} `json:"dimensions,omitempty"` + Metrics map[string]float64 `json:"metrics,omitempty"` +} + +// MetricsResponse is the response from any of the metrics GET endpoints. +type MetricsResponse = []MetricDataPoint + +// MetricsQueryParams holds shared query parameters for all metrics endpoints. +// Start and End are required (ISO 8601 date-time). +// ConnectionID is mapped to API webhook_id in the CLI layer. +type MetricsQueryParams struct { + Start string // required, ISO 8601 + End string // required, ISO 8601 + Granularity string // e.g. 1h, 5m, 1d (pattern: \d+(s|m|h|d|w|M)) + Measures []string + Dimensions []string + SourceID string + DestinationID string + ConnectionID string // sent as filters[webhook_id] + Status string // e.g. SUCCESSFUL, FAILED + IssueID string // sent as filters[issue_id]; required for events-by-issue +} + +// buildMetricsQuery builds the query string for metrics endpoints. +// Uses bracket notation: date_range[start], date_range[end], filters[webhook_id], etc. +func buildMetricsQuery(p MetricsQueryParams) string { + q := url.Values{} + q.Set("date_range[start]", p.Start) + q.Set("date_range[end]", p.End) + if p.Granularity != "" { + q.Set("granularity", p.Granularity) + } + for _, m := range p.Measures { + q.Add("measures[]", m) + } + for _, d := range p.Dimensions { + q.Add("dimensions[]", d) + } + if p.SourceID != "" { + q.Set("filters[source_id]", p.SourceID) + } + if p.DestinationID != "" { + q.Set("filters[destination_id]", p.DestinationID) + } + if p.ConnectionID != "" { + q.Set("filters[webhook_id]", p.ConnectionID) + } + if p.Status != "" { + q.Set("filters[status]", p.Status) + } + if p.IssueID != "" { + q.Set("filters[issue_id]", p.IssueID) + } + return q.Encode() +} + +// metricsResponseWrapper is used when the API returns an object with a "data" array instead of a raw array. +type metricsResponseWrapper struct { + Data MetricsResponse `json:"data"` +} + +func (c *Client) queryMetrics(ctx context.Context, path string, params MetricsQueryParams) (MetricsResponse, error) { + queryStr := buildMetricsQuery(params) + resp, err := c.Get(ctx, APIPathPrefix+path, queryStr, nil) + if err != nil { + return nil, err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to read metrics response: %w", err) + } + // Try as array first (most endpoints return []MetricDataPoint). + var result MetricsResponse + if err := json.Unmarshal(body, &result); err == nil { + return result, nil + } + // Some endpoints may return {"data": [...]}. + var wrapped metricsResponseWrapper + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&wrapped); err != nil { + return nil, fmt.Errorf("failed to parse metrics response: %w", err) + } + return wrapped.Data, nil +} + +// QueryEventMetrics returns event metrics (GET /metrics/events). +func (c *Client) QueryEventMetrics(ctx context.Context, params MetricsQueryParams) (MetricsResponse, error) { + return c.queryMetrics(ctx, "/metrics/events", params) +} + +// QueryRequestMetrics returns request metrics (GET /metrics/requests). +func (c *Client) QueryRequestMetrics(ctx context.Context, params MetricsQueryParams) (MetricsResponse, error) { + return c.queryMetrics(ctx, "/metrics/requests", params) +} + +// QueryAttemptMetrics returns attempt metrics (GET /metrics/attempts). +func (c *Client) QueryAttemptMetrics(ctx context.Context, params MetricsQueryParams) (MetricsResponse, error) { + return c.queryMetrics(ctx, "/metrics/attempts", params) +} + +// QueryQueueDepth returns queue depth metrics (GET /metrics/queue-depth). +func (c *Client) QueryQueueDepth(ctx context.Context, params MetricsQueryParams) (MetricsResponse, error) { + return c.queryMetrics(ctx, "/metrics/queue-depth", params) +} + +// QueryEventsPendingTimeseries returns events pending timeseries (GET /metrics/events-pending-timeseries). +func (c *Client) QueryEventsPendingTimeseries(ctx context.Context, params MetricsQueryParams) (MetricsResponse, error) { + return c.queryMetrics(ctx, "/metrics/events-pending-timeseries", params) +} + +// QueryEventsByIssue returns events grouped by issue (GET /metrics/events-by-issue). +func (c *Client) QueryEventsByIssue(ctx context.Context, params MetricsQueryParams) (MetricsResponse, error) { + return c.queryMetrics(ctx, "/metrics/events-by-issue", params) +} + +// QueryTransformationMetrics returns transformation metrics (GET /metrics/transformations). +func (c *Client) QueryTransformationMetrics(ctx context.Context, params MetricsQueryParams) (MetricsResponse, error) { + return c.queryMetrics(ctx, "/metrics/transformations", params) +} diff --git a/test/acceptance/metrics_test.go b/test/acceptance/metrics_test.go new file mode 100644 index 0000000..6367a9f --- /dev/null +++ b/test/acceptance/metrics_test.go @@ -0,0 +1,270 @@ +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// metricsStart and metricsEnd define a fixed date range for metrics acceptance tests. +// Use a past range that the API will accept. +const metricsStart = "2025-01-01T00:00:00Z" +const metricsEnd = "2025-01-02T00:00:00Z" + +func metricsArgs(subcmd string, extra ...string) []string { + args := []string{"gateway", "metrics", subcmd, "--start", metricsStart, "--end", metricsEnd} + return append(args, extra...) +} + +// TestMetricsHelp verifies that hookdeck gateway metrics --help lists all 7 subcommands. +func TestMetricsHelp(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "metrics", "--help") + assert.Contains(t, stdout, "events") + assert.Contains(t, stdout, "requests") + assert.Contains(t, stdout, "attempts") + assert.Contains(t, stdout, "queue-depth") + assert.Contains(t, stdout, "pending") + assert.Contains(t, stdout, "events-by-issue") + assert.Contains(t, stdout, "transformations") +} + +// Baseline: one success test per endpoint. API requires at least one measure for most endpoints. +func TestMetricsEvents(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "count")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsRequests(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("requests"), "--measures", "count")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsAttempts(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("attempts"), "--measures", "count")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsQueueDepth(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("queue-depth"), "--measures", "max_depth")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsPending(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("pending"), "--measures", "count")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsEventsByIssue(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + // events-by-issue requires issue-id as positional argument and --measures + stdout := cli.RunExpectSuccess("gateway", "metrics", "events-by-issue", "iss_placeholder", "--start", metricsStart, "--end", metricsEnd, "--measures", "count") + assert.NotEmpty(t, stdout) +} + +func TestMetricsTransformations(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("transformations"), "--measures", "count")...) + assert.NotEmpty(t, stdout) +} + +// Common flags: granularity, measures, dimensions, source-id, destination-id, connection-id, output. +func TestMetricsEventsWithGranularity(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--granularity", "1d", "--measures", "count")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsEventsWithMeasures(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "count,failed_count")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsQueueDepthWithMeasuresAndDimensions(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("queue-depth"), "--measures", "max_depth,max_age", "--dimensions", "destination_id")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsEventsWithSourceID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + // Filter by a placeholder ID; API may return empty data but command should succeed + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "count", "--source-id", "src_placeholder")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsEventsWithConnectionID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "count", "--connection-id", "web_placeholder")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsEventsWithDestinationID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "count", "--destination-id", "dst_placeholder")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsEventsWithStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("events"), "--measures", "count", "--status", "SUCCESSFUL")...) + assert.NotEmpty(t, stdout) +} + +// Output: JSON structure (array of objects with time_bucket, dimensions, metrics). +func TestMetricsEventsOutputJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + var data []struct { + TimeBucket *string `json:"time_bucket"` + Dimensions map[string]interface{} `json:"dimensions"` + Metrics map[string]float64 `json:"metrics"` + } + require.NoError(t, cli.RunJSON(&data, append(metricsArgs("events"), "--measures", "count")...)) + // Response is an array; may be empty + assert.NotNil(t, data) +} + +func TestMetricsQueueDepthOutputJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + var data []struct { + TimeBucket *string `json:"time_bucket"` + Dimensions map[string]interface{} `json:"dimensions"` + Metrics map[string]float64 `json:"metrics"` + } + require.NoError(t, cli.RunJSON(&data, append(metricsArgs("queue-depth"), "--measures", "max_depth")...)) + assert.NotNil(t, data) +} + +// Validation: missing --start or --end should fail. +func TestMetricsEventsMissingStart(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "metrics", "events", "--end", metricsEnd) + require.Error(t, err) +} + +func TestMetricsEventsMissingEnd(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "metrics", "events", "--start", metricsStart) + require.Error(t, err) +} + +func TestMetricsRequestsMissingStart(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "metrics", "requests", "--end", metricsEnd) + require.Error(t, err) +} + +func TestMetricsAttemptsMissingEnd(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "metrics", "attempts", "--start", metricsStart) + require.Error(t, err) +} + +// Missing --measures: API returns 422 (measures required for all endpoints). +func TestMetricsEventsMissingMeasures(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "metrics", "events", "--start", metricsStart, "--end", metricsEnd) + require.Error(t, err) +} + +// events-by-issue without required argument: Cobra rejects (ExactArgs(1)). +func TestMetricsEventsByIssueMissingIssueID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "metrics", "events-by-issue", "--start", metricsStart, "--end", metricsEnd, "--measures", "count") + require.Error(t, err) +} + +// Pending and transformations with minimal flags. +func TestMetricsPendingWithGranularity(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("pending"), "--granularity", "1h", "--measures", "count")...) + assert.NotEmpty(t, stdout) +} + +func TestMetricsTransformationsWithMeasures(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess(append(metricsArgs("transformations"), "--measures", "count,error_rate")...) + assert.NotEmpty(t, stdout) +} From 2ae2c6d8fbc6bd95261f45a346ba79515a438275 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 26 Feb 2026 08:06:06 +0000 Subject: [PATCH 3/5] chore(metrics): tag all metrics subcommands with ShortBeta/LongBeta Use ShortBeta and LongBeta on all 7 metrics subcommands so help text shows [BETA] and feedback link, consistent with other gateway commands. Made-with: Cursor --- pkg/cmd/metrics_attempts.go | 4 ++-- pkg/cmd/metrics_events.go | 4 ++-- pkg/cmd/metrics_events_by_issue.go | 4 ++-- pkg/cmd/metrics_pending.go | 4 ++-- pkg/cmd/metrics_queue_depth.go | 4 ++-- pkg/cmd/metrics_requests.go | 4 ++-- pkg/cmd/metrics_transformations.go | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/metrics_attempts.go b/pkg/cmd/metrics_attempts.go index 1d24ee1..9644744 100644 --- a/pkg/cmd/metrics_attempts.go +++ b/pkg/cmd/metrics_attempts.go @@ -19,8 +19,8 @@ func newMetricsAttemptsCmd() *metricsAttemptsCmd { c.cmd = &cobra.Command{ Use: "attempts", Args: cobra.NoArgs, - Short: "Query attempt metrics", - Long: `Query metrics for delivery attempts (latency, success/failure). Measures: ` + metricsAttemptsMeasures + `.`, + Short: ShortBeta("Query attempt metrics"), + Long: LongBeta(`Query metrics for delivery attempts (latency, success/failure). Measures: ` + metricsAttemptsMeasures + `.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags) diff --git a/pkg/cmd/metrics_events.go b/pkg/cmd/metrics_events.go index 1f30169..3fe9229 100644 --- a/pkg/cmd/metrics_events.go +++ b/pkg/cmd/metrics_events.go @@ -19,8 +19,8 @@ func newMetricsEventsCmd() *metricsEventsCmd { c.cmd = &cobra.Command{ Use: "events", Args: cobra.NoArgs, - Short: "Query event metrics", - Long: `Query metrics for events (volume, success/failure counts, error rate, etc.). Measures: ` + metricsEventsMeasures + `.`, + Short: ShortBeta("Query event metrics"), + Long: LongBeta(`Query metrics for events (volume, success/failure counts, error rate, etc.). Measures: ` + metricsEventsMeasures + `.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags) diff --git a/pkg/cmd/metrics_events_by_issue.go b/pkg/cmd/metrics_events_by_issue.go index 54d1acc..51997bf 100644 --- a/pkg/cmd/metrics_events_by_issue.go +++ b/pkg/cmd/metrics_events_by_issue.go @@ -19,8 +19,8 @@ func newMetricsEventsByIssueCmd() *metricsEventsByIssueCmd { c.cmd = &cobra.Command{ Use: "events-by-issue ", Args: validators.ExactArgs(1), - Short: "Query events grouped by issue", - Long: `Query metrics for events grouped by issue (for debugging). Requires issue ID as argument.`, + Short: ShortBeta("Query events grouped by issue"), + Long: LongBeta(`Query metrics for events grouped by issue (for debugging). Requires issue ID as argument.`), RunE: c.runE, } addMetricsCommonFlagsEx(c.cmd, &c.flags, true) diff --git a/pkg/cmd/metrics_pending.go b/pkg/cmd/metrics_pending.go index 5e7ec21..eb8e4d4 100644 --- a/pkg/cmd/metrics_pending.go +++ b/pkg/cmd/metrics_pending.go @@ -17,8 +17,8 @@ func newMetricsPendingCmd() *metricsPendingCmd { c.cmd = &cobra.Command{ Use: "pending", Args: cobra.NoArgs, - Short: "Query events pending timeseries", - Long: `Query events pending over time (timeseries). Measures: count.`, + Short: ShortBeta("Query events pending timeseries"), + Long: LongBeta(`Query events pending over time (timeseries). Measures: count.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags) diff --git a/pkg/cmd/metrics_queue_depth.go b/pkg/cmd/metrics_queue_depth.go index acd1e79..b1f0fea 100644 --- a/pkg/cmd/metrics_queue_depth.go +++ b/pkg/cmd/metrics_queue_depth.go @@ -20,8 +20,8 @@ func newMetricsQueueDepthCmd() *metricsQueueDepthCmd { c.cmd = &cobra.Command{ Use: "queue-depth", Args: cobra.NoArgs, - Short: "Query queue depth metrics", - Long: `Query queue depth metrics. Measures: ` + metricsQueueDepthMeasures + `. Dimensions: ` + metricsQueueDepthDimensions + `.`, + Short: ShortBeta("Query queue depth metrics"), + Long: LongBeta(`Query queue depth metrics. Measures: ` + metricsQueueDepthMeasures + `. Dimensions: ` + metricsQueueDepthDimensions + `.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags) diff --git a/pkg/cmd/metrics_requests.go b/pkg/cmd/metrics_requests.go index d2cf352..084dbf1 100644 --- a/pkg/cmd/metrics_requests.go +++ b/pkg/cmd/metrics_requests.go @@ -19,8 +19,8 @@ func newMetricsRequestsCmd() *metricsRequestsCmd { c.cmd = &cobra.Command{ Use: "requests", Args: cobra.NoArgs, - Short: "Query request metrics", - Long: `Query metrics for requests (acceptance, rejection, etc.). Measures: ` + metricsRequestsMeasures + `.`, + Short: ShortBeta("Query request metrics"), + Long: LongBeta(`Query metrics for requests (acceptance, rejection, etc.). Measures: ` + metricsRequestsMeasures + `.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags) diff --git a/pkg/cmd/metrics_transformations.go b/pkg/cmd/metrics_transformations.go index be9bf47..a47b6e8 100644 --- a/pkg/cmd/metrics_transformations.go +++ b/pkg/cmd/metrics_transformations.go @@ -19,8 +19,8 @@ func newMetricsTransformationsCmd() *metricsTransformationsCmd { c.cmd = &cobra.Command{ Use: "transformations", Args: cobra.NoArgs, - Short: "Query transformation metrics", - Long: `Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`, + Short: ShortBeta("Query transformation metrics"), + Long: LongBeta(`Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`), RunE: c.runE, } addMetricsCommonFlags(c.cmd, &c.flags) From 45e6d23c13c6c4f4cd6ce8e5ccd240bb85e95752 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:09:57 +0000 Subject: [PATCH 4/5] Update package.json version to 1.9.0-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eeaa20d..261e932 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "1.8.0", + "version": "1.9.0-beta.1", "description": "Hookdeck CLI", "repository": { "type": "git", From fbfeb4399ba8ad51ef8de9865927e2120ad7cf5b Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 26 Feb 2026 17:44:48 +0000 Subject: [PATCH 5/5] fix(generate-reference): fix bugs in metrics docs generation Fixes in tools/generate-reference/main.go for correct generation of CLI metrics docs (e.g. usage line escaping, table formatting). No CLI binary change; docs generator only. Made-with: Cursor --- package-lock.json | 4 ++-- tools/generate-reference/main.go | 30 +++++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e198cbd..b70a512 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hookdeck-cli", - "version": "1.6.0", + "version": "1.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "hookdeck-cli", - "version": "1.6.0", + "version": "1.8.0", "license": "Apache-2.0", "bin": { "hookdeck": "bin/hookdeck.js" diff --git a/tools/generate-reference/main.go b/tools/generate-reference/main.go index 6f80efc..06c3dc4 100644 --- a/tools/generate-reference/main.go +++ b/tools/generate-reference/main.go @@ -361,7 +361,8 @@ func globalFlagsTable(root *cobra.Command) string { } else { flag = fmt.Sprintf("`--%s`", f.name) } - usage := strings.ReplaceAll(f.usage, "|", "\\|") + usage := normalizeUsageForTable(f.usage) + usage = strings.ReplaceAll(usage, "|", "\\|") b.WriteString(fmt.Sprintf("| %s | `%s` | %s |\n", flag, f.ftype, usage)) } return b.String() @@ -412,7 +413,8 @@ func generateGlobalFlags(root *cobra.Command) string { } else { flag = fmt.Sprintf("`--%s`", f.name) } - usage := strings.ReplaceAll(f.usage, "|", "\\|") + usage := normalizeUsageForTable(f.usage) + usage = strings.ReplaceAll(usage, "|", "\\|") b.WriteString(fmt.Sprintf("| %s | `%s` | %s |\n", flag, f.ftype, usage)) } return b.String() @@ -569,7 +571,8 @@ func commandSection(root *cobra.Command, c *cobra.Command, wrap wrapConfig, glob mainBuf.WriteString("| Flag | Type | Description |\n") mainBuf.WriteString("|------|------|-------------|\n") for _, r := range flagRows { - usage := wrapFlagsInBackticks(r.usage) + usage := normalizeUsageForTable(r.usage) + usage = wrapFlagsInBackticks(usage) usage = strings.ReplaceAll(usage, "|", "\\|") mainBuf.WriteString(fmt.Sprintf("| %s | %s | %s |\n", r.flag, r.ftype, usage)) } @@ -688,6 +691,27 @@ func extractExamplesFromLong(long string) (prose, examplesBlock string) { return prose, examplesBlock } +// escapeAngleBracketsForMarkdown replaces < and > with HTML entities so Markdoc and +// other parsers do not treat placeholders like or as HTML tags. +// Use for any generator output that is embedded in markdown (usage lines, table cells). +func escapeAngleBracketsForMarkdown(s string) string { + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// normalizeUsageForTable collapses newlines and extra spaces in flag usage so markdown +// table rows stay on one line. Escapes angle brackets so Markdoc does not treat them +// as HTML tags (e.g. "" in granularity help). Use for any flag description +// emitted into a markdown table. +func normalizeUsageForTable(s string) string { + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + s = regexp.MustCompile(`\s+`).ReplaceAllString(s, " ") + s = escapeAngleBracketsForMarkdown(s) + return strings.TrimSpace(s) +} + // wrapFlagsInBackticks wraps flag references (--flag-name) in backticks for markdown. // Skips segments already inside backticks to avoid double-wrapping (RE2 has no lookbehind). var flagLongRE = regexp.MustCompile(`--([a-zA-Z][a-zA-Z0-9_-]*)`)