From 3cf4c1d192c8ebc4ce95e46c4305c69bf264989d Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 17 Jun 2026 08:56:25 -0400 Subject: [PATCH 1/3] feat(tasks,emails): add search subcommands with filter/sort support Add `hspt tasks search` and `hspt emails search` backed by the HubSpot CRM Search API. Both accept repeatable `--filter` and `--sort` flags plus `--limit`, `--after`, and `--properties`, matching the existing search commands' look and feel. New shared parsing helpers (internal/cmd/shared/search.go): - ParseFilters: shorthand (prop=val, !=, >=, <=, >, <) and explicit operators (prop:OPERATOR:val, prop:BETWEEN:lo:hi, prop:IN:a,b,c, prop:HAS_PROPERTY). ISO-8601 dates on known date properties are auto-converted to Unix milliseconds. - ParseSort: prop, prop:asc, prop:desc (case-insensitive). The api.SearchFilter struct gains optional highValue (BETWEEN) and values (IN/NOT_IN) fields; this is additive and backward compatible. Tests cover the filter/sort parsers (shared) and request serialization for tasks/emails search including BETWEEN/IN encoding (api). Closes #53 --- README.md | 21 +++ api/crm.go | 8 +- api/crm_test.go | 62 +++++++ internal/cmd/emails/emails.go | 99 +++++++++++ internal/cmd/shared/search.go | 261 +++++++++++++++++++++++++++ internal/cmd/shared/search_test.go | 274 +++++++++++++++++++++++++++++ internal/cmd/tasks/tasks.go | 99 +++++++++++ 7 files changed, 821 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/shared/search.go create mode 100644 internal/cmd/shared/search_test.go diff --git a/README.md b/README.md index a8ecbea..e3f3bbf 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,27 @@ hspt tasks create --subject "Follow up" --body "Call about renewal" --priority H hspt calls create --body "Discussed pricing" --direction OUTBOUND --duration 300 ``` +The `tasks` and `emails` commands also support a `search` subcommand backed by the +HubSpot CRM Search API, with repeatable `--filter` and `--sort` flags: + +```bash +# Open tasks for a specific owner, oldest first +hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hubspot_owner_id=77999105" --sort "hs_timestamp:asc" + +# Overdue tasks (not started, due on or before a date — ISO dates are converted automatically) +hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hs_timestamp<=2026-03-17" --sort "hs_timestamp:asc" + +# Outbound emails, newest first +hspt emails search --filter "hs_email_direction=EMAIL" --sort "hs_timestamp:desc" --limit 20 + +# Emails whose subject contains a phrase +hspt emails search --filter "hs_email_subject:CONTAINS_TOKEN:Dev Academy" --limit 10 +``` + +Filters accept shorthand (`prop=value`, `prop!=value`, `prop>=value`, `prop<=value`, +`prop>value`, `prop 0 { + req.FilterGroups = []api.SearchFilterGroup{ + {Filters: filters}, + } + } + + result, err := client.SearchObjects(api.ObjectTypeEmails, req) + if err != nil { + return err + } + + if len(result.Results) == 0 { + v.Info("No emails found matching criteria") + return nil + } + + headers := []string{"ID", "SUBJECT", "DIRECTION", "STATUS", "TIMESTAMP"} + rows := make([][]string, 0, len(result.Results)) + for _, obj := range result.Results { + rows = append(rows, []string{ + obj.ID, + truncate(obj.GetProperty("hs_email_subject"), 40), + obj.GetProperty("hs_email_direction"), + obj.GetProperty("hs_email_status"), + obj.GetProperty("hs_timestamp"), + }) + } + + v.Info("Found %d email(s)", len(result.Results)) + if err := v.Render(headers, rows, result); err != nil { + return err + } + + if result.Paging != nil && result.Paging.Next != nil { + v.Info("\nMore results available. Use --after %s to get the next page.", result.Paging.Next.After) + } + + return nil + }, + } + + cmd.Flags().StringArrayVar(&filterArgs, "filter", nil, "Filter condition (e.g. prop=value, prop>=value, prop:OPERATOR:value); repeatable") + cmd.Flags().StringArrayVar(&sortArgs, "sort", nil, "Sort condition (e.g. hs_timestamp:asc or hs_timestamp:desc); repeatable") + cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of results") + cmd.Flags().StringVar(&after, "after", "", "Pagination cursor for the next page") + cmd.Flags().StringSliceVar(&properties, "properties", nil, "Properties to include (comma-separated)") + + return cmd +} + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s diff --git a/internal/cmd/shared/search.go b/internal/cmd/shared/search.go new file mode 100644 index 0000000..9c3f80a --- /dev/null +++ b/internal/cmd/shared/search.go @@ -0,0 +1,261 @@ +package shared + +import ( + "fmt" + "strings" + "time" + + "github.com/open-cli-collective/hubspot-cli/api" +) + +// shorthandOperators maps shorthand comparison tokens to HubSpot search +// operators. Longer tokens are listed first so prefix matching does not +// mistake ">=" for ">". +var shorthandOperators = []struct { + token string + operator string +}{ + {">=", "GTE"}, + {"<=", "LTE"}, + {"!=", "NEQ"}, + {"=", "EQ"}, + {">", "GT"}, + {"<", "LT"}, +} + +// dateProperties are HubSpot properties whose values are stored as Unix +// millisecond timestamps. ISO-8601 dates supplied for these properties are +// converted automatically so users can write human-readable dates. +var dateProperties = map[string]bool{ + "hs_timestamp": true, + "hs_task_timestamp": true, + "hs_createdate": true, + "hs_lastmodifieddate": true, + "createdate": true, + "lastmodifieddate": true, + "closedate": true, + "hs_task_completion_date": true, + "hubspot_owner_assigneddate": true, +} + +// ParseFilters converts raw --filter flag values into HubSpot search filters. +// +// Supported forms: +// +// prop=value EQ +// prop!=value NEQ +// prop>=value GTE +// prop<=value LTE +// prop>value GT +// prop 0 { + prop := trimmed[:idx] + rest := trimmed[idx+1:] + opEnd := strings.Index(rest, ":") + op := rest + if opEnd >= 0 { + op = rest[:opEnd] + } + if isOperatorToken(op) { + return parseExplicitFilter(prop, strings.ToUpper(op), rest, opEnd) + } + } + + // Shorthand comparison form: propvalue. + for _, sh := range shorthandOperators { + if idx := strings.Index(trimmed, sh.token); idx > 0 { + prop := strings.TrimSpace(trimmed[:idx]) + value := strings.TrimSpace(trimmed[idx+len(sh.token):]) + if prop == "" { + return api.SearchFilter{}, fmt.Errorf("filter %q is missing a property name", raw) + } + return api.SearchFilter{ + PropertyName: prop, + Operator: sh.operator, + Value: convertDateValue(prop, value), + }, nil + } + } + + return api.SearchFilter{}, fmt.Errorf("invalid filter %q: expected prop=value, prop>=value, or prop:OPERATOR:value", raw) +} + +func parseExplicitFilter(prop, operator, rest string, opEnd int) (api.SearchFilter, error) { + if prop == "" { + return api.SearchFilter{}, fmt.Errorf("filter is missing a property name") + } + + // Operators that take no value. + if opEnd < 0 { + switch operator { + case "HAS_PROPERTY", "NOT_HAS_PROPERTY": + return api.SearchFilter{PropertyName: prop, Operator: operator}, nil + default: + return api.SearchFilter{}, fmt.Errorf("operator %s requires a value (use %s:%s:value)", operator, prop, operator) + } + } + + value := rest[opEnd+1:] + + switch operator { + case "BETWEEN": + parts := strings.SplitN(value, ":", 2) + if len(parts) != 2 { + return api.SearchFilter{}, fmt.Errorf("BETWEEN requires two values (use %s:BETWEEN:low:high)", prop) + } + return api.SearchFilter{ + PropertyName: prop, + Operator: operator, + Value: convertDateValue(prop, parts[0]), + HighValue: convertDateValue(prop, parts[1]), + }, nil + case "IN", "NOT_IN": + var values []string + for _, v := range strings.Split(value, ",") { + v = strings.TrimSpace(v) + if v != "" { + values = append(values, convertDateValue(prop, v)) + } + } + if len(values) == 0 { + return api.SearchFilter{}, fmt.Errorf("%s requires at least one value (use %s:%s:a,b,c)", operator, prop, operator) + } + return api.SearchFilter{PropertyName: prop, Operator: operator, Values: values}, nil + default: + return api.SearchFilter{ + PropertyName: prop, + Operator: operator, + Value: convertDateValue(prop, value), + }, nil + } +} + +// isOperatorToken reports whether s looks like a HubSpot operator name +// (uppercase letters and underscores, at least two characters). This keeps +// the explicit-form detection from swallowing shorthand values. +func isOperatorToken(s string) bool { + if len(s) < 2 { + return false + } + for _, r := range s { + if (r < 'A' || r > 'Z') && r != '_' { + return false + } + } + return true +} + +// convertDateValue converts an ISO-8601 date/datetime to a Unix millisecond +// string when the property is a known date property. Non-date properties and +// values that are already numeric or unparseable are returned unchanged. +func convertDateValue(prop, value string) string { + value = strings.TrimSpace(value) + if value == "" || !dateProperties[prop] { + return value + } + + // Already a Unix millisecond timestamp. + if isAllDigits(value) { + return value + } + + layouts := []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, value); err == nil { + return fmt.Sprintf("%d", t.UTC().UnixMilli()) + } + } + + return value +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// ParseSort converts raw --sort flag values into HubSpot search sorts. +// +// Supported forms: +// +// prop ascending (default) +// prop:asc ascending +// prop:desc descending +func ParseSort(raw []string) ([]api.SearchSort, error) { + var sorts []api.SearchSort + + for _, r := range raw { + trimmed := strings.TrimSpace(r) + if trimmed == "" { + return nil, fmt.Errorf("empty sort") + } + + prop := trimmed + direction := "ASCENDING" + + if idx := strings.LastIndex(trimmed, ":"); idx > 0 { + prop = strings.TrimSpace(trimmed[:idx]) + dir := strings.ToLower(strings.TrimSpace(trimmed[idx+1:])) + switch dir { + case "asc", "ascending": + direction = "ASCENDING" + case "desc", "descending": + direction = "DESCENDING" + default: + return nil, fmt.Errorf("invalid sort direction %q in %q (use asc or desc)", dir, r) + } + } + + if prop == "" { + return nil, fmt.Errorf("sort %q is missing a property name", r) + } + + sorts = append(sorts, api.SearchSort{PropertyName: prop, Direction: direction}) + } + + return sorts, nil +} diff --git a/internal/cmd/shared/search_test.go b/internal/cmd/shared/search_test.go new file mode 100644 index 0000000..951de0b --- /dev/null +++ b/internal/cmd/shared/search_test.go @@ -0,0 +1,274 @@ +package shared + +import ( + "reflect" + "testing" + + "github.com/open-cli-collective/hubspot-cli/api" +) + +func TestParseFilters(t *testing.T) { + tests := []struct { + name string + input []string + want []api.SearchFilter + wantErr bool + }{ + { + name: "shorthand EQ", + input: []string{"hs_task_status=NOT_STARTED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "EQ", Value: "NOT_STARTED"}, + }, + }, + { + name: "shorthand NEQ", + input: []string{"hs_email_status!=BOUNCED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_email_status", Operator: "NEQ", Value: "BOUNCED"}, + }, + }, + { + name: "shorthand GTE", + input: []string{"hs_task_priority>=2"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_priority", Operator: "GTE", Value: "2"}, + }, + }, + { + name: "shorthand LTE does not split on equals", + input: []string{"hubspot_owner_id<=99"}, + want: []api.SearchFilter{ + {PropertyName: "hubspot_owner_id", Operator: "LTE", Value: "99"}, + }, + }, + { + name: "shorthand GT", + input: []string{"amount>100"}, + want: []api.SearchFilter{ + {PropertyName: "amount", Operator: "GT", Value: "100"}, + }, + }, + { + name: "shorthand LT", + input: []string{"amount<100"}, + want: []api.SearchFilter{ + {PropertyName: "amount", Operator: "LT", Value: "100"}, + }, + }, + { + name: "explicit operator with value", + input: []string{"hs_email_subject:CONTAINS_TOKEN:Dev Academy"}, + want: []api.SearchFilter{ + {PropertyName: "hs_email_subject", Operator: "CONTAINS_TOKEN", Value: "Dev Academy"}, + }, + }, + { + name: "explicit EQ operator", + input: []string{"hs_task_status:EQ:NOT_STARTED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "EQ", Value: "NOT_STARTED"}, + }, + }, + { + // Lowercase tokens after the first colon are treated as part of a + // shorthand value, not as an explicit operator. With no shorthand + // comparison token present this is an error, which keeps values that + // happen to contain colons (URLs, times) from being misparsed. + name: "lowercase operator-looking token is not an explicit operator", + input: []string{"hs_email_subject:contains_token:hello"}, + wantErr: true, + }, + { + name: "HAS_PROPERTY without value", + input: []string{"hubspot_owner_id:HAS_PROPERTY"}, + want: []api.SearchFilter{ + {PropertyName: "hubspot_owner_id", Operator: "HAS_PROPERTY"}, + }, + }, + { + name: "BETWEEN range", + input: []string{"hs_task_priority:BETWEEN:1:3"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_priority", Operator: "BETWEEN", Value: "1", HighValue: "3"}, + }, + }, + { + name: "IN set membership", + input: []string{"hs_task_status:IN:NOT_STARTED,IN_PROGRESS"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "IN", Values: []string{"NOT_STARTED", "IN_PROGRESS"}}, + }, + }, + { + name: "NOT_IN set membership", + input: []string{"hs_task_status:NOT_IN:COMPLETED,DEFERRED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "NOT_IN", Values: []string{"COMPLETED", "DEFERRED"}}, + }, + }, + { + name: "ISO date converted to unix millis for date property (shorthand)", + input: []string{"hs_timestamp<=2026-03-17"}, + want: []api.SearchFilter{ + // 2026-03-17T00:00:00Z == 1773705600000 ms + {PropertyName: "hs_timestamp", Operator: "LTE", Value: "1773705600000"}, + }, + }, + { + name: "ISO datetime converted for date property", + input: []string{"hs_timestamp=2026-03-17T12:00:00Z"}, + want: []api.SearchFilter{ + {PropertyName: "hs_timestamp", Operator: "EQ", Value: "1773748800000"}, + }, + }, + { + name: "BETWEEN with ISO dates converts both bounds", + input: []string{"hs_timestamp:BETWEEN:2026-03-17:2026-03-18"}, + want: []api.SearchFilter{ + {PropertyName: "hs_timestamp", Operator: "BETWEEN", Value: "1773705600000", HighValue: "1773792000000"}, + }, + }, + { + name: "numeric value for date property is left unchanged", + input: []string{"hs_timestamp>=1773705600000"}, + want: []api.SearchFilter{ + {PropertyName: "hs_timestamp", Operator: "GTE", Value: "1773705600000"}, + }, + }, + { + name: "date-looking value on non-date property is not converted", + input: []string{"hs_email_subject=2026-03-17"}, + want: []api.SearchFilter{ + {PropertyName: "hs_email_subject", Operator: "EQ", Value: "2026-03-17"}, + }, + }, + { + name: "multiple filters", + input: []string{"hs_task_status=NOT_STARTED", "hubspot_owner_id=77999105"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "EQ", Value: "NOT_STARTED"}, + {PropertyName: "hubspot_owner_id", Operator: "EQ", Value: "77999105"}, + }, + }, + { + name: "empty filter is an error", + input: []string{""}, + wantErr: true, + }, + { + name: "no operator is an error", + input: []string{"hs_task_status"}, + wantErr: true, + }, + { + name: "missing property name is an error", + input: []string{"=NOT_STARTED"}, + wantErr: true, + }, + { + name: "BETWEEN with one value is an error", + input: []string{"hs_task_priority:BETWEEN:1"}, + wantErr: true, + }, + { + name: "value operator without value is an error", + input: []string{"hs_email_subject:CONTAINS_TOKEN"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFilters(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseFilters(%v) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("ParseFilters(%v) unexpected error: %v", tt.input, err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseFilters(%v) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseFiltersEmptyInput(t *testing.T) { + got, err := ParseFilters(nil) + if err != nil { + t.Fatalf("ParseFilters(nil) unexpected error: %v", err) + } + if got != nil { + t.Errorf("ParseFilters(nil) = %+v, want nil", got) + } +} + +func TestParseSort(t *testing.T) { + tests := []struct { + name string + input []string + want []api.SearchSort + wantErr bool + }{ + { + name: "ascending explicit", + input: []string{"hs_timestamp:asc"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "ASCENDING"}}, + }, + { + name: "descending explicit", + input: []string{"hs_timestamp:desc"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "DESCENDING"}}, + }, + { + name: "default direction is ascending", + input: []string{"hs_timestamp"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "ASCENDING"}}, + }, + { + name: "long form directions", + input: []string{"createdate:descending", "amount:ascending"}, + want: []api.SearchSort{ + {PropertyName: "createdate", Direction: "DESCENDING"}, + {PropertyName: "amount", Direction: "ASCENDING"}, + }, + }, + { + name: "case-insensitive direction", + input: []string{"hs_timestamp:DESC"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "DESCENDING"}}, + }, + { + name: "invalid direction is an error", + input: []string{"hs_timestamp:sideways"}, + wantErr: true, + }, + { + name: "empty sort is an error", + input: []string{""}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSort(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseSort(%v) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("ParseSort(%v) unexpected error: %v", tt.input, err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseSort(%v) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/cmd/tasks/tasks.go b/internal/cmd/tasks/tasks.go index 65f2e4f..f90d0bc 100644 --- a/internal/cmd/tasks/tasks.go +++ b/internal/cmd/tasks/tasks.go @@ -8,6 +8,7 @@ import ( "github.com/open-cli-collective/hubspot-cli/api" "github.com/open-cli-collective/hubspot-cli/internal/cmd/root" + "github.com/open-cli-collective/hubspot-cli/internal/cmd/shared" ) // DefaultProperties are the default properties to fetch for tasks @@ -26,6 +27,7 @@ func Register(parent *cobra.Command, opts *root.Options) { cmd.AddCommand(newCreateCmd(opts)) cmd.AddCommand(newUpdateCmd(opts)) cmd.AddCommand(newDeleteCmd(opts)) + cmd.AddCommand(newSearchCmd(opts)) parent.AddCommand(cmd) } @@ -361,6 +363,103 @@ func newDeleteCmd(opts *root.Options) *cobra.Command { return cmd } +func newSearchCmd(opts *root.Options) *cobra.Command { + var filterArgs []string + var sortArgs []string + var limit int + var after string + var properties []string + + cmd := &cobra.Command{ + Use: "search", + Short: "Search tasks", + Long: "Search tasks using the HubSpot CRM Search API with filtering and sorting.", + Example: ` # Open tasks for a specific owner, oldest first + hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hubspot_owner_id=77999105" --sort "hs_timestamp:asc" + + # Overdue tasks (not started, due on or before a date) + hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hs_timestamp<=2026-03-17" --sort "hs_timestamp:asc" + + # Tasks whose subject contains a phrase + hspt tasks search --filter "hs_task_subject:CONTAINS_TOKEN:renewal" --limit 25`, + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if len(properties) == 0 { + properties = DefaultProperties + } + + filters, err := shared.ParseFilters(filterArgs) + if err != nil { + return err + } + + sorts, err := shared.ParseSort(sortArgs) + if err != nil { + return err + } + + req := api.SearchRequest{ + Properties: properties, + Limit: limit, + After: after, + Sorts: sorts, + } + if len(filters) > 0 { + req.FilterGroups = []api.SearchFilterGroup{ + {Filters: filters}, + } + } + + result, err := client.SearchObjects(api.ObjectTypeTasks, req) + if err != nil { + return err + } + + if len(result.Results) == 0 { + v.Info("No tasks found matching criteria") + return nil + } + + headers := []string{"ID", "SUBJECT", "STATUS", "PRIORITY", "TIMESTAMP"} + rows := make([][]string, 0, len(result.Results)) + for _, obj := range result.Results { + rows = append(rows, []string{ + obj.ID, + truncate(obj.GetProperty("hs_task_subject"), 40), + obj.GetProperty("hs_task_status"), + obj.GetProperty("hs_task_priority"), + obj.GetProperty("hs_timestamp"), + }) + } + + v.Info("Found %d task(s)", len(result.Results)) + if err := v.Render(headers, rows, result); err != nil { + return err + } + + if result.Paging != nil && result.Paging.Next != nil { + v.Info("\nMore results available. Use --after %s to get the next page.", result.Paging.Next.After) + } + + return nil + }, + } + + cmd.Flags().StringArrayVar(&filterArgs, "filter", nil, "Filter condition (e.g. prop=value, prop>=value, prop:OPERATOR:value); repeatable") + cmd.Flags().StringArrayVar(&sortArgs, "sort", nil, "Sort condition (e.g. hs_timestamp:asc or hs_timestamp:desc); repeatable") + cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of results") + cmd.Flags().StringVar(&after, "after", "", "Pagination cursor for the next page") + cmd.Flags().StringSliceVar(&properties, "properties", nil, "Properties to include (comma-separated)") + + return cmd +} + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s From acff80de25745041750300f790e0243020629ccc Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 17 Jun 2026 09:42:38 -0400 Subject: [PATCH 2/3] refactor(tasks,emails): share search command + assert filter content Address review feedback on PR #57: - api/crm_test.go: the "search tasks sends filter, sort, and pagination" subtest previously only asserted the filterGroups/sorts keys existed, so a bug emitting an empty filter or dropping the operator would still pass. Add content assertions on the EQ filter (propertyName/operator/value) and the sort (propertyName/direction), matching the depth of the BETWEEN/IN subtest. - internal/cmd/shared: extract NewSearchCmd(opts, SearchCmdConfig) so the two near-identical tasks/emails newSearchCmd implementations delegate to a single shared constructor, keeping the reuse boundary consistent with the existing ParseFilters/ParseSort helpers. Object-specific pieces (object type, noun, descriptions, default properties, headers, row builder) are supplied via the config; no behavior change. --- api/crm_test.go | 13 +++- internal/cmd/emails/emails.go | 99 ++++---------------------- internal/cmd/shared/searchcmd.go | 118 +++++++++++++++++++++++++++++++ internal/cmd/tasks/tasks.go | 99 ++++---------------------- 4 files changed, 159 insertions(+), 170 deletions(-) create mode 100644 internal/cmd/shared/searchcmd.go diff --git a/api/crm_test.go b/api/crm_test.go index a880921..1184919 100644 --- a/api/crm_test.go +++ b/api/crm_test.go @@ -457,8 +457,17 @@ func TestClient_SearchObjects(t *testing.T) { assert.Len(t, result.Results, 1) assert.Equal(t, float64(50), body["limit"]) assert.Equal(t, "cursor123", body["after"]) - assert.Contains(t, body, "filterGroups") - assert.Contains(t, body, "sorts") + + groups := body["filterGroups"].([]interface{}) + f := groups[0].(map[string]interface{})["filters"].([]interface{})[0].(map[string]interface{}) + assert.Equal(t, "hs_task_status", f["propertyName"]) + assert.Equal(t, "EQ", f["operator"]) + assert.Equal(t, "NOT_STARTED", f["value"]) + + sorts := body["sorts"].([]interface{}) + s := sorts[0].(map[string]interface{}) + assert.Equal(t, "hs_timestamp", s["propertyName"]) + assert.Equal(t, "ASCENDING", s["direction"]) }) t.Run("search emails serializes BETWEEN highValue and IN values", func(t *testing.T) { diff --git a/internal/cmd/emails/emails.go b/internal/cmd/emails/emails.go index 2b2d2ab..75140e2 100644 --- a/internal/cmd/emails/emails.go +++ b/internal/cmd/emails/emails.go @@ -360,16 +360,11 @@ func newDeleteCmd(opts *root.Options) *cobra.Command { } func newSearchCmd(opts *root.Options) *cobra.Command { - var filterArgs []string - var sortArgs []string - var limit int - var after string - var properties []string - - cmd := &cobra.Command{ - Use: "search", - Short: "Search email engagements", - Long: "Search email engagements using the HubSpot CRM Search API with filtering and sorting.", + return shared.NewSearchCmd(opts, shared.SearchCmdConfig{ + ObjectType: api.ObjectTypeEmails, + Noun: "email", + Short: "Search email engagements", + Long: "Search email engagements using the HubSpot CRM Search API with filtering and sorting.", Example: ` # Outbound emails, newest first hspt emails search --filter "hs_email_direction=EMAIL" --sort "hs_timestamp:desc" --limit 20 @@ -378,82 +373,18 @@ func newSearchCmd(opts *root.Options) *cobra.Command { # Emails for a specific owner within a date range hspt emails search --filter "hubspot_owner_id=77999105" --filter "hs_timestamp:BETWEEN:2026-01-01:2026-03-01"`, - RunE: func(cmd *cobra.Command, args []string) error { - v := opts.View() - - client, err := opts.APIClient() - if err != nil { - return err - } - - if len(properties) == 0 { - properties = DefaultProperties - } - - filters, err := shared.ParseFilters(filterArgs) - if err != nil { - return err - } - - sorts, err := shared.ParseSort(sortArgs) - if err != nil { - return err - } - - req := api.SearchRequest{ - Properties: properties, - Limit: limit, - After: after, - Sorts: sorts, - } - if len(filters) > 0 { - req.FilterGroups = []api.SearchFilterGroup{ - {Filters: filters}, - } - } - - result, err := client.SearchObjects(api.ObjectTypeEmails, req) - if err != nil { - return err - } - - if len(result.Results) == 0 { - v.Info("No emails found matching criteria") - return nil - } - - headers := []string{"ID", "SUBJECT", "DIRECTION", "STATUS", "TIMESTAMP"} - rows := make([][]string, 0, len(result.Results)) - for _, obj := range result.Results { - rows = append(rows, []string{ - obj.ID, - truncate(obj.GetProperty("hs_email_subject"), 40), - obj.GetProperty("hs_email_direction"), - obj.GetProperty("hs_email_status"), - obj.GetProperty("hs_timestamp"), - }) + DefaultProperties: DefaultProperties, + Headers: []string{"ID", "SUBJECT", "DIRECTION", "STATUS", "TIMESTAMP"}, + Row: func(obj api.CRMObject) []string { + return []string{ + obj.ID, + truncate(obj.GetProperty("hs_email_subject"), 40), + obj.GetProperty("hs_email_direction"), + obj.GetProperty("hs_email_status"), + obj.GetProperty("hs_timestamp"), } - - v.Info("Found %d email(s)", len(result.Results)) - if err := v.Render(headers, rows, result); err != nil { - return err - } - - if result.Paging != nil && result.Paging.Next != nil { - v.Info("\nMore results available. Use --after %s to get the next page.", result.Paging.Next.After) - } - - return nil }, - } - - cmd.Flags().StringArrayVar(&filterArgs, "filter", nil, "Filter condition (e.g. prop=value, prop>=value, prop:OPERATOR:value); repeatable") - cmd.Flags().StringArrayVar(&sortArgs, "sort", nil, "Sort condition (e.g. hs_timestamp:asc or hs_timestamp:desc); repeatable") - cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of results") - cmd.Flags().StringVar(&after, "after", "", "Pagination cursor for the next page") - cmd.Flags().StringSliceVar(&properties, "properties", nil, "Properties to include (comma-separated)") - - return cmd + }) } func truncate(s string, maxLen int) string { diff --git a/internal/cmd/shared/searchcmd.go b/internal/cmd/shared/searchcmd.go new file mode 100644 index 0000000..32fd876 --- /dev/null +++ b/internal/cmd/shared/searchcmd.go @@ -0,0 +1,118 @@ +package shared + +import ( + "github.com/spf13/cobra" + + "github.com/open-cli-collective/hubspot-cli/api" + "github.com/open-cli-collective/hubspot-cli/internal/cmd/root" +) + +// SearchCmdConfig describes the object-specific pieces of a `search` subcommand. +// Everything else (flag wiring, filter/sort parsing, the search request, +// pagination, and rendering) is shared across object types. +type SearchCmdConfig struct { + // ObjectType is the HubSpot CRM object type to search. + ObjectType api.ObjectType + // Noun is the singular human-readable object name (e.g. "task", "email") + // used in the empty-result and result-count messages. + Noun string + // Short and Long are the cobra command descriptions. + Short string + Long string + // Example is the cobra command example text. + Example string + // DefaultProperties are fetched when the user does not pass --properties. + DefaultProperties []string + // Headers are the table column headers. + Headers []string + // Row maps a result object to a table row. It is called once per result and + // must return values aligned with Headers. + Row func(obj api.CRMObject) []string +} + +// NewSearchCmd builds a `search` subcommand for a CRM object type. The +// object-specific behavior is supplied via cfg; the command wiring, filter/sort +// parsing (via ParseFilters/ParseSort), request building, pagination, and +// rendering are shared across object types. +func NewSearchCmd(opts *root.Options, cfg SearchCmdConfig) *cobra.Command { + var filterArgs []string + var sortArgs []string + var limit int + var after string + var properties []string + + cmd := &cobra.Command{ + Use: "search", + Short: cfg.Short, + Long: cfg.Long, + Example: cfg.Example, + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if len(properties) == 0 { + properties = cfg.DefaultProperties + } + + filters, err := ParseFilters(filterArgs) + if err != nil { + return err + } + + sorts, err := ParseSort(sortArgs) + if err != nil { + return err + } + + req := api.SearchRequest{ + Properties: properties, + Limit: limit, + After: after, + Sorts: sorts, + } + if len(filters) > 0 { + req.FilterGroups = []api.SearchFilterGroup{ + {Filters: filters}, + } + } + + result, err := client.SearchObjects(cfg.ObjectType, req) + if err != nil { + return err + } + + if len(result.Results) == 0 { + v.Info("No %ss found matching criteria", cfg.Noun) + return nil + } + + rows := make([][]string, 0, len(result.Results)) + for _, obj := range result.Results { + rows = append(rows, cfg.Row(obj)) + } + + v.Info("Found %d %s(s)", len(result.Results), cfg.Noun) + if err := v.Render(cfg.Headers, rows, result); err != nil { + return err + } + + if result.Paging != nil && result.Paging.Next != nil { + v.Info("\nMore results available. Use --after %s to get the next page.", result.Paging.Next.After) + } + + return nil + }, + } + + cmd.Flags().StringArrayVar(&filterArgs, "filter", nil, "Filter condition (e.g. prop=value, prop>=value, prop:OPERATOR:value); repeatable") + cmd.Flags().StringArrayVar(&sortArgs, "sort", nil, "Sort condition (e.g. hs_timestamp:asc or hs_timestamp:desc); repeatable") + cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of results") + cmd.Flags().StringVar(&after, "after", "", "Pagination cursor for the next page") + cmd.Flags().StringSliceVar(&properties, "properties", nil, "Properties to include (comma-separated)") + + return cmd +} diff --git a/internal/cmd/tasks/tasks.go b/internal/cmd/tasks/tasks.go index f90d0bc..735cfb0 100644 --- a/internal/cmd/tasks/tasks.go +++ b/internal/cmd/tasks/tasks.go @@ -364,16 +364,11 @@ func newDeleteCmd(opts *root.Options) *cobra.Command { } func newSearchCmd(opts *root.Options) *cobra.Command { - var filterArgs []string - var sortArgs []string - var limit int - var after string - var properties []string - - cmd := &cobra.Command{ - Use: "search", - Short: "Search tasks", - Long: "Search tasks using the HubSpot CRM Search API with filtering and sorting.", + return shared.NewSearchCmd(opts, shared.SearchCmdConfig{ + ObjectType: api.ObjectTypeTasks, + Noun: "task", + Short: "Search tasks", + Long: "Search tasks using the HubSpot CRM Search API with filtering and sorting.", Example: ` # Open tasks for a specific owner, oldest first hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hubspot_owner_id=77999105" --sort "hs_timestamp:asc" @@ -382,82 +377,18 @@ func newSearchCmd(opts *root.Options) *cobra.Command { # Tasks whose subject contains a phrase hspt tasks search --filter "hs_task_subject:CONTAINS_TOKEN:renewal" --limit 25`, - RunE: func(cmd *cobra.Command, args []string) error { - v := opts.View() - - client, err := opts.APIClient() - if err != nil { - return err - } - - if len(properties) == 0 { - properties = DefaultProperties - } - - filters, err := shared.ParseFilters(filterArgs) - if err != nil { - return err - } - - sorts, err := shared.ParseSort(sortArgs) - if err != nil { - return err - } - - req := api.SearchRequest{ - Properties: properties, - Limit: limit, - After: after, - Sorts: sorts, - } - if len(filters) > 0 { - req.FilterGroups = []api.SearchFilterGroup{ - {Filters: filters}, - } - } - - result, err := client.SearchObjects(api.ObjectTypeTasks, req) - if err != nil { - return err - } - - if len(result.Results) == 0 { - v.Info("No tasks found matching criteria") - return nil - } - - headers := []string{"ID", "SUBJECT", "STATUS", "PRIORITY", "TIMESTAMP"} - rows := make([][]string, 0, len(result.Results)) - for _, obj := range result.Results { - rows = append(rows, []string{ - obj.ID, - truncate(obj.GetProperty("hs_task_subject"), 40), - obj.GetProperty("hs_task_status"), - obj.GetProperty("hs_task_priority"), - obj.GetProperty("hs_timestamp"), - }) + DefaultProperties: DefaultProperties, + Headers: []string{"ID", "SUBJECT", "STATUS", "PRIORITY", "TIMESTAMP"}, + Row: func(obj api.CRMObject) []string { + return []string{ + obj.ID, + truncate(obj.GetProperty("hs_task_subject"), 40), + obj.GetProperty("hs_task_status"), + obj.GetProperty("hs_task_priority"), + obj.GetProperty("hs_timestamp"), } - - v.Info("Found %d task(s)", len(result.Results)) - if err := v.Render(headers, rows, result); err != nil { - return err - } - - if result.Paging != nil && result.Paging.Next != nil { - v.Info("\nMore results available. Use --after %s to get the next page.", result.Paging.Next.After) - } - - return nil }, - } - - cmd.Flags().StringArrayVar(&filterArgs, "filter", nil, "Filter condition (e.g. prop=value, prop>=value, prop:OPERATOR:value); repeatable") - cmd.Flags().StringArrayVar(&sortArgs, "sort", nil, "Sort condition (e.g. hs_timestamp:asc or hs_timestamp:desc); repeatable") - cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of results") - cmd.Flags().StringVar(&after, "after", "", "Pagination cursor for the next page") - cmd.Flags().StringSliceVar(&properties, "properties", nil, "Properties to include (comma-separated)") - - return cmd + }) } func truncate(s string, maxLen int) string { From 1ebfa408db0c08559f7e88a1c3d2e15a6ab53c24 Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 17 Jun 2026 10:32:05 -0400 Subject: [PATCH 3/3] test(crm): use assert.NoError inside test handler goroutines Calling require.NoError inside an httptest http.HandlerFunc runs t.FailNow off the test goroutine, which is undefined behavior. Use assert.NoError so the handler only marks failure and drains the body. Also clarify the emails search README example: hs_email_direction is a direction enum, so the comment now reads 'Logged emails by direction' rather than implying outbound-only semantics. --- README.md | 2 +- api/crm_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e3f3bbf..ae60a06 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hubspot_owner_ # Overdue tasks (not started, due on or before a date — ISO dates are converted automatically) hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hs_timestamp<=2026-03-17" --sort "hs_timestamp:asc" -# Outbound emails, newest first +# Logged emails by direction, newest first hspt emails search --filter "hs_email_direction=EMAIL" --sort "hs_timestamp:desc" --limit 20 # Emails whose subject contains a phrase diff --git a/api/crm_test.go b/api/crm_test.go index 1184919..c034bd7 100644 --- a/api/crm_test.go +++ b/api/crm_test.go @@ -435,7 +435,7 @@ func TestClient_SearchObjects(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/crm/v3/objects/tasks/search", r.URL.Path) assert.Equal(t, http.MethodPost, r.Method) - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"results": [{"id": "1", "properties": {"hs_task_status": "NOT_STARTED"}}]}`)) })) @@ -474,7 +474,7 @@ func TestClient_SearchObjects(t *testing.T) { var body map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/crm/v3/objects/emails/search", r.URL.Path) - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"results": []}`)) }))