Skip to content

feat(tasks,emails): add search subcommands with filter/sort support#57

Open
piekstra wants to merge 3 commits into
mainfrom
feat/search-tasks-emails
Open

feat(tasks,emails): add search subcommands with filter/sort support#57
piekstra wants to merge 3 commits into
mainfrom
feat/search-tasks-emails

Conversation

@piekstra

Copy link
Copy Markdown

Closes #53

Summary

Adds hspt tasks search and hspt emails search, backed by the HubSpot CRM Search API, with generic filtering and sorting — so you can find open tasks for an owner, overdue tasks, outbound emails, etc. without paginating the entire history and filtering client-side.

Both subcommands accept the same flags as the issue requested:

  • --filter (repeatable)
  • --sort (repeatable)
  • --limit, --after, --properties

Filter syntax

Shorthand comparisons:

Form Operator
prop=value EQ
prop!=value NEQ
prop>=value GTE
prop<=value LTE
prop>value GT
prop<value LT

Explicit operators (uppercase, HubSpot convention):

  • prop:OPERATOR:value — e.g. hs_email_subject:CONTAINS_TOKEN:Dev Academy
  • prop:BETWEEN:low:high
  • prop:IN:a,b,c / prop:NOT_IN:a,b,c
  • prop:HAS_PROPERTY / prop:NOT_HAS_PROPERTY

ISO-8601 dates supplied for known date properties (e.g. hs_timestamp) are auto-converted to Unix milliseconds, so hs_timestamp<=2026-03-17 works as expected.

Sort syntax

prop, prop:asc, prop:desc (case-insensitive; defaults to ascending).

Examples

# 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
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 by subject
hspt emails search --filter "hs_email_subject:CONTAINS_TOKEN:Dev Academy" --limit 10

Implementation notes

The issue references shared.ParseFilters() / shared.ParseSort() as already existing, but they did not exist in this repo (only shared/format.go was present, and the existing contacts/deals/tickets search commands use hardcoded per-field flags). This PR introduces those generic helpers in internal/cmd/shared/search.go and wires the two new subcommands to them.

  • api.SearchFilter gains optional highValue (BETWEEN) and values (IN/NOT_IN) fields — additive and backward compatible; existing search commands are unaffected.
  • New subcommands follow the existing newSearchCmd registration pattern and the table/JSON/plain rendering via v.Render().

Files changed

  • internal/cmd/shared/search.go (new) — ParseFilters / ParseSort
  • internal/cmd/shared/search_test.go (new) — parser unit tests
  • internal/cmd/tasks/tasks.go — register + implement search
  • internal/cmd/emails/emails.go — register + implement search
  • api/crm.goSearchFilter.HighValue / .Values
  • api/crm_test.go — request-serialization tests for tasks/emails search (incl. BETWEEN/IN)
  • README.md — document the new subcommands

Testing

All commands run from the repo root.

$ go test -race ./...
ok  github.com/open-cli-collective/hubspot-cli/api
ok  github.com/open-cli-collective/hubspot-cli/internal/cmd/configcmd
ok  github.com/open-cli-collective/hubspot-cli/internal/cmd/shared
(all other packages: no test files)
# PASS

$ golangci-lint run ./...
0 issues.

$ make build
# succeeds -> bin/hspt

Manual smoke tests against bin/hspt:

  • hspt tasks search --help / hspt emails search --help show the new flags and examples.
  • Validation errors surface cleanly, e.g.:
    • --sort "hs_timestamp:sideways" -> invalid sort direction "sideways" ... (use asc or desc)
    • --filter "hs_task_status" -> invalid filter "hs_task_status": expected prop=value, ...

(Live API calls were not exercised — no HubSpot token in this environment — but request construction is covered by the api package tests.)

@piekstra piekstra marked this pull request as ready for review June 17, 2026 12:57
@piekstra piekstra requested a review from piekstra-dev June 17, 2026 12:57
Comment thread api/crm_test.go Outdated
Comment thread internal/cmd/tasks/tasks.go
@piekstra-dev

Copy link
Copy Markdown

Automated PR Review

Reviewed commit: d9d1e00cfba5
Profile: reviewer - Posting as: piekstra-dev

Summary

Reviewer Findings
documentation:docs 0
go:implementation-tests 1
policies:conventions 1
go:implementation-tests (1 finding)

Minor - api/crm_test.go:460

The "search tasks sends filter, sort, and pagination" sub-test only asserts that filterGroups and sorts keys are present in the request body — not their content. A serialisation bug that emitted "filterGroups": [{}] or omitted the filter operator entirely would still pass. This is the primary test path for a plain EQ filter round-trip through SearchRequest → JSON, and its correctness is now particularly load-bearing given the struct was just extended with HighValue and Values.

Add at least one content assertion on the filter:

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"])

The second sub-test already does this depth of inspection for BETWEEN/IN, so the pattern is established.

policies:conventions (1 finding)

Minor - internal/cmd/tasks/tasks.go:363

The newSearchCmd implementations in tasks.go and emails.go are nearly identical — they differ only in object type, table headers, and example text. The same PR that introduced internal/cmd/shared to hold reusable filter/sort logic did not extract the shared command scaffold, so any future object type (calls, contacts, etc.) would require a third copy.

Suggested fix: add a constructor in internal/cmd/shared (e.g. NewSearchCmd(opts, objectType, headers, defaultProperties, examples)) and delegate from each object-specific newSearchCmd. This keeps the reuse boundary consistent with what was already established by ParseFilters/ParseSort.

0 PR discussion threads considered. 0 summarized; 0 resolved.


Completed in 3m 59s | ~$0.52 (est.) | claude-sonnet-4-6 | cr 0.4.161
Field Value
Model claude-sonnet-4-6
Reviewers documentation:docs, go:implementation-tests, policies:conventions
Engine claude_cli · claude-sonnet-4-6
Reviewed by cr · piekstra-dev
Duration 3m 59s wall · 3m 57s compute
Cost ~$0.52 (est.)
Tokens 20 in / 12.5k out

Per-workstream usage

Workstream Model In Out Cache read Cache create Cost Duration
orchestrator-selection claude-sonnet-4-6 4 1.6k 7.2k 12.4k ~$0.07 (est.) 29s
documentation:docs claude-sonnet-4-6 4 1.6k 7.2k 12.2k ~$0.07 (est.) 31s
go:implementation-tests claude-sonnet-4-6 4 6.9k 7.2k 25.9k ~$0.20 (est.) 1m 59s
policies:conventions claude-sonnet-4-6 4 2.1k 7.2k 24.2k ~$0.12 (est.) 45s
orchestrator-rollup claude-sonnet-4-6 4 393 17.1k 11.3k ~$0.05 (est.) 12s

@piekstra-dev piekstra-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review completed with outcome: approved.

piekstra added a commit that referenced this pull request Jun 17, 2026
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.
@piekstra piekstra requested a review from piekstra-dev June 17, 2026 13:43
Comment thread internal/cmd/shared/searchcmd.go
Comment thread api/crm_test.go Outdated
Comment thread internal/cmd/shared/search.go
Comment thread README.md
Comment thread internal/cmd/shared/searchcmd.go
Comment thread api/crm_test.go
Comment thread internal/cmd/shared/search.go
@piekstra-dev

Copy link
Copy Markdown

Automated PR Review

Reviewed commit: b71b099fd543
Profile: reviewer - Posting as: piekstra-dev

Summary

Reviewer Findings
documentation:docs 1
go:implementation-tests 2
policies:conventions 0
structure:repo-health 2
documentation:docs (1 finding)

Minor - README.md:204

The comment says "Outbound emails" but the filter is hs_email_direction=EMAIL. In HubSpot's engagement model, EMAIL is the engagement type (i.e., it is an email engagement record), not the direction. The direction property typically holds values like OUTBOUND or INBOUND. If the intent is truly to filter for outbound emails, the filter should likely be hs_email_direction=OUTBOUND; if the intent is just to list email-type engagements, the comment should drop the word "Outbound" to avoid teaching the wrong pattern to readers who copy-paste this example.

go:implementation-tests (2 findings)

Minor - api/crm_test.go:438

require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) is called inside the http.HandlerFunc goroutine, which is not the goroutine running the test function. Go's testing docs explicitly state that t.FailNow() (which require calls internally) must only be called from the test goroutine; calling it from another goroutine causes a panic or undefined behavior. Replace both occurrences (one in each new sub-test's handler) with assert.NoError so the handler merely marks the test failed and lets it drain cleanly:

assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))

Minor - internal/cmd/shared/searchcmd.go:80

The RunE lambda contains non-trivial logic that is not covered by any test in the diff:

  1. Filter grouping (lines ~82-84): all parsed filters are unconditionally wrapped into a single SearchFilterGroup. If ParseFilters returns filters but len(filters) == 0 is false, this wrapping is the only place that contracts multiple --filter flags into the correct HubSpot request shape. A future change that accidentally emits multiple groups or drops the wrapping would go undetected.
  2. Empty-result branch (line ~88): the v.Info("No %ss found...") path is never exercised.
  3. Pagination footer (lines ~107-109): the "More results available" message is conditional on result.Paging != nil && result.Paging.Next != nil and is likewise untested.

Add a table-driven test for NewSearchCmd using a fake/stub api.Client (or the existing httptest pattern from api/crm_test.go) that covers at least: (a) filters collapsed into one group in the outbound request, (b) empty-result output, and (c) pagination footer when Paging.Next is set.

structure:repo-health (2 findings)

Major - internal/cmd/shared/search.go:28

The dateProperties allowlist is a hardcoded contract baked into a shared parsing utility. When a user supplies a human-readable date for a property that is not in this map (e.g., a custom date property, or a future HubSpot property like hs_meeting_start_time), convertDateValue silently passes the string through unchanged. The HubSpot API then receives a non-numeric value where it expects Unix milliseconds, producing an API error or—worse—a silent mismatch with no user-visible warning. This list will drift as the API evolves and as new object types are added.

The list also belongs at the wrong layer: date-property semantics are an API contract, not a CLI parsing detail. Fix: move the date-property set into the api package (or a per-object-type metadata struct on SearchCmdConfig) so it is co-located with the types it describes and can be evolved alongside them. Alternatively, suppress silent pass-through: if a value looks like an ISO date but the property is not in the known list, emit a warning rather than silently forwarding the unconverted string.

Minor - internal/cmd/shared/search.go

ParseFilters and ParseSort are non-trivial shared utilities with multiple parsing forms (=, !=, >=, <=, >, <, :OP, :OP:val, :BETWEEN:lo:hi, :IN:a,b,c), a heuristic operator-token detector, and silent date auto-conversion. No test file appears in this diff. Because every future object type's search subcommand will call these functions, bugs in edge cases (e.g., property names containing =, URLs as values, the isOperatorToken heuristic misfiring) compound across callers without a test safety net. Fix: add table-driven unit tests for ParseFilters and ParseSort covering the documented forms, invalid inputs, and the operator-detection boundary cases before this utility spreads further.

2 PR discussion threads considered. 2 summarized; 2 resolved.


Completed in 4m 47s | ~$0.66 (est.) | claude-sonnet-4-6 | cr 0.4.161
Field Value
Model claude-sonnet-4-6
Reviewers documentation:docs, go:implementation-tests, policies:conventions, structure:repo-health
Engine claude_cli · claude-sonnet-4-6
Reviewed by cr · piekstra-dev
Duration 4m 47s wall · 4m 45s compute
Cost ~$0.66 (est.)
Tokens 24 in / 13.3k out

Per-workstream usage

Workstream Model In Out Cache read Cache create Cost Duration
orchestrator-selection claude-sonnet-4-6 4 2.3k 7.2k 15.0k ~$0.09 (est.) 45s
documentation:docs claude-sonnet-4-6 4 1.5k 7.2k 12.2k ~$0.07 (est.) 28s
go:implementation-tests claude-sonnet-4-6 4 3.5k 7.2k 25.9k ~$0.15 (est.) 1m 13s
policies:conventions claude-sonnet-4-6 4 3.3k 7.2k 23.4k ~$0.14 (est.) 1m 04s
structure:repo-health claude-sonnet-4-6 4 2.2k 7.2k 19.3k ~$0.11 (est.) 45s
orchestrator-rollup claude-sonnet-4-6 4 548 8.3k 22.6k ~$0.10 (est.) 28s

@piekstra-dev piekstra-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review completed with outcome: comment.

piekstra added 3 commits June 17, 2026 12:30
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
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.
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.
@piekstra piekstra force-pushed the feat/search-tasks-emails branch from fbe75f7 to 1ebfa40 Compare June 17, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add search subcommands for tasks and emails with filter/sort support

2 participants