Skip to content

Commit ef5da8f

Browse files
authored
Migrate all commands to typed SDK service methods (#91)
* Add SDK integration layer: client init, error mapping, and test infrastructure Wire up fizzy-sdk as a dependency with SDK client initialization in root PersistentPreRunE, typed error conversion (sdk_errors.go), normalizeAny() for struct→map round-trips, and httptest-backed mock infrastructure that lets existing MockClient tests exercise real SDK service methods. * Migrate all commands to typed SDK service methods Replace raw HTTP calls (getClient().Get/Post/Patch/Delete) with typed SDK service methods (Cards().Create, Boards().Update, etc.) across all command files. Legacy getClient() retained only for file upload/download, multipart PATCH (avatar), and board migration. * Update AGENTS.md for SDK architecture and remove dead --tag-ids flag * Address PR review findings: 7 fixes across commands - step update: bypass omitempty on bool via raw Patch when --not_completed set - requireAuth: decouple from SDK init so legacy commands aren't blocked - card create: follow Location header when API returns empty body - setup: nil-guard accMap["id"] in parseAccounts - pin_test: use validation error (422) instead of duplicate not-found - setup_test: use toJSON helper instead of manual json.Marshal - test_helpers: replace fragile "nauthorized" match with ToLower * Fix e2e harness isolation and attachment test panics Use FIZZY_PROFILE env var + clean HOME temp dir instead of --account flag to avoid profile store resolution. Guard type assertions in attachment download tests to prevent nil panics. * Fix auth e2e tests: pass FIZZY_PROFILE and FIZZY_NO_KEYRING to Execute Auth login/logout tests use harness.Execute() directly (not h.Run()), so they must explicitly provide the env vars that globalEnv() normally injects. Without FIZZY_PROFILE the CLI errors with "No profile configured". * Address PR review feedback - Fix CodeQL allocation overflow alert in column.go (len+3 flagged as potentially large) - Remove double normalizeAny in step update --not_completed path - Add configHome temp dir to NewWithConfig for test isolation - Set Retryable: true on transient sentinel errors (circuit breaker, bulkhead, rate limit) * Address PR review feedback: omitempty bools, network errors, upload SDK - board update --all_access false: use raw Patch when setting false to avoid omitempty dropping the zero value (same pattern as step --not_completed) - comment update: only set Body on request when --body or --body_file provided, preventing empty string from clearing comment text - sdk_errors: catch raw net.Error as CodeNetwork for proper exit codes - upload: use requireAuth+requireAccount instead of requireAuthAndAccount since upload only uses legacy client, not SDK - AGENTS.md: update stale jsonAny docs to reflect normalizeAny * Upgrade fizzy-sdk to v0.1.0 No breaking changes for the CLI — just moving from pre-release commit pin to the tagged release. * Add printSuccessWithLocation for webhook merge compatibility The webhook commands (merged to master in #96) call printSuccessWithLocation which was added to root.go on master. Our branch has a diverged root.go, so CI's merge produces an undefined reference. Add the function here so the merge builds. * Fix board breadcrumb test to use SDK test mode The breadcrumb test merged from master (#96) used SetTestMode (legacy client), but board list uses getSDK() on this branch. Switch to SetTestModeWithSDK to match. * Fix CodeQL allocation overflow alert in column list Guard the slice capacity computation to satisfy CodeQL's size-overflow analysis. * Address PR review feedback: marshal errors and URL validation - Handle json.Marshal errors in signup identity data processing instead of silently discarding them - Validate hostname presence for all URL schemes in validateAPIURL, not just http://
1 parent 48fb6f1 commit ef5da8f

47 files changed

Lines changed: 2079 additions & 1403 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,32 @@
66
fizzy-cli/
77
├── cmd/fizzy/ # Main entrypoint
88
├── internal/
9-
│ ├── client/ # HTTP client wrapper
9+
│ ├── client/ # Legacy HTTP client (upload, download, multipart, migrate)
1010
│ ├── commands/ # Command implementations
1111
│ ├── config/ # Configuration management
1212
│ ├── errors/ # Error handling and types
13-
│ └── response/ # Response formatting
13+
│ └── render/ # Output rendering (styled, markdown, columns)
1414
├── e2e/ # Go integration tests
1515
├── skills/ # Agent skills
1616
└── .claude-plugin/ # Claude Code integration
1717
```
1818

19+
## SDK Architecture
20+
21+
Commands use the fizzy-sdk (`github.com/basecamp/fizzy-sdk/go/pkg/fizzy`) for API access:
22+
23+
- **`getSDK()`** returns `*fizzy.AccountClient` — account-scoped SDK client for most commands
24+
- **`getSDKClient()`** returns `*fizzy.Client` — root client for account-independent operations (e.g. identity)
25+
- **`getClient()`** (deprecated) returns `client.API` — legacy client, used only for file upload, download, multipart PATCH, and board migration
26+
27+
SDK service methods return typed structs (e.g. `*generated.Board`, `[]generated.Card`). Helper functions convert to the `any` types the output layer expects:
28+
- **`normalizeAny(data)`** — JSON-round-trips any value (typed structs, `json.RawMessage`, maps) to `map[string]any` / `[]map[string]any`
29+
- **`jsonAnySlice(pages)`** — converts `[]json.RawMessage` from `GetAll()` pagination to `[]map[string]any`
30+
- **`convertSDKError(err)`** — converts SDK errors to `*output.Error`
31+
- **`toSliceAny(v)`** — normalizes `[]map[string]any` or `[]any` to `[]any` for iteration
32+
33+
Account can be a slug or numeric ID.
34+
1935
## Fizzy API Reference
2036

2137
API documentation: https://github.com/basecamp/fizzy/blob/main/docs/API.md
@@ -39,6 +55,23 @@ make test-run NAME=TestBoardCRUD # Run a specific test
3955

4056
Requirements: Go 1.26+, API credentials for e2e tests.
4157

58+
### Unit Test Patterns
59+
60+
Tests use `SetTestModeWithSDK(mock)` which creates an httptest server backed by `MockClient`:
61+
62+
```go
63+
mock := NewMockClient()
64+
mock.GetResponse = &client.APIResponse{Data: map[string]any{"id": "1"}}
65+
SetTestModeWithSDK(mock)
66+
SetTestConfig("token", "account", "https://api.example.com")
67+
defer resetTest()
68+
```
69+
70+
- `mock.OnGet(path, resp)` — route-specific GET responses
71+
- `mock.WithGetData(data)` — set default GET response data
72+
- `mock.WithListData(data)` — set GetWithPagination response (used as fallback for GET when GetResponse is default empty map)
73+
- JSON round-trip through httptest converts `int``float64` — use `float64(n)` in assertions
74+
4275
E2E environment variables:
4376
- `FIZZY_TEST_TOKEN` - API token (required)
4477
- `FIZZY_TEST_ACCOUNT` - Account slug (required)

SURFACE.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,6 @@ FLAG fizzy card create --markdown type=bool
450450
FLAG fizzy card create --profile type=string
451451
FLAG fizzy card create --quiet type=bool
452452
FLAG fizzy card create --styled type=bool
453-
FLAG fizzy card create --tag-ids type=string
454453
FLAG fizzy card create --title type=string
455454
FLAG fizzy card create --token type=string
456455
FLAG fizzy card create --verbose type=bool

e2e/harness/harness.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ type Harness struct {
2828
// Cleanup tracks created resources for cleanup
2929
Cleanup *CleanupTracker
3030

31+
// configHome is a temporary directory used as HOME to isolate from host config
32+
configHome string
33+
3134
// t is the testing context
3235
t *testing.T
3336
}
@@ -137,12 +140,19 @@ func New(t *testing.T) *Harness {
137140
t.Skip("FIZZY_TEST_ACCOUNT not set, skipping integration tests")
138141
}
139142

143+
tmpDir, err := os.MkdirTemp("", "fizzy-e2e-*")
144+
if err != nil {
145+
t.Fatalf("failed to create temp config dir: %v", err)
146+
}
147+
t.Cleanup(func() { os.RemoveAll(tmpDir) })
148+
140149
return &Harness{
141150
BinaryPath: cfg.BinaryPath,
142151
Token: cfg.Token,
143152
Account: cfg.Account,
144153
APIURL: cfg.APIURL,
145154
Cleanup: NewCleanupTracker(),
155+
configHome: tmpDir,
146156
t: t,
147157
}
148158
}
@@ -151,12 +161,19 @@ func New(t *testing.T) *Harness {
151161
func NewWithConfig(t *testing.T, cfg *Config) *Harness {
152162
t.Helper()
153163

164+
tmpDir, err := os.MkdirTemp("", "fizzy-e2e-*")
165+
if err != nil {
166+
t.Fatalf("failed to create temp config dir: %v", err)
167+
}
168+
t.Cleanup(func() { os.RemoveAll(tmpDir) })
169+
154170
return &Harness{
155171
BinaryPath: cfg.BinaryPath,
156172
Token: cfg.Token,
157173
Account: cfg.Account,
158174
APIURL: cfg.APIURL,
159175
Cleanup: NewCleanupTracker(),
176+
configHome: tmpDir,
160177
t: t,
161178
}
162179
}
@@ -174,8 +191,14 @@ func (h *Harness) RunWithEnv(env map[string]string, args ...string) *Result {
174191
// Build full argument list with global options
175192
fullArgs := h.buildArgs(args...)
176193

194+
// Merge global env (FIZZY_PROFILE) with caller-provided env
195+
mergedEnv := h.globalEnv()
196+
for k, v := range env {
197+
mergedEnv[k] = v
198+
}
199+
177200
// Execute the command
178-
result := Execute(h.BinaryPath, fullArgs, env)
201+
result := Execute(h.BinaryPath, fullArgs, mergedEnv)
179202

180203
// Try to parse JSON response
181204
if result.Stdout != "" {
@@ -211,17 +234,25 @@ func (h *Harness) RunWithoutAuth(args ...string) *Result {
211234
}
212235

213236
// buildArgs builds the full argument list with global options.
214-
// Thor requires global options to come AFTER the subcommand.
215237
func (h *Harness) buildArgs(args ...string) []string {
216238
globalArgs := []string{
217239
"--token", h.Token,
218-
"--account", h.Account,
219240
"--api-url", h.APIURL,
220241
}
221242
// Append global args after the command args
222243
return append(args, globalArgs...)
223244
}
224245

246+
// globalEnv returns environment variables for the test harness.
247+
// Uses a temporary HOME to isolate from host config/keyring.
248+
func (h *Harness) globalEnv() map[string]string {
249+
return map[string]string{
250+
"FIZZY_PROFILE": h.Account,
251+
"FIZZY_NO_KEYRING": "1",
252+
"HOME": h.configHome,
253+
}
254+
}
255+
225256
// GetDataString extracts a string value from the response data.
226257
func (r *Result) GetDataString(key string) string {
227258
if r.Response == nil || r.Response.Data == nil {

e2e/tests/attachment_test.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -346,9 +346,13 @@ func TestCardAttachmentsDownload(t *testing.T) {
346346

347347
// Check response data
348348
data := result.GetDataMap()
349-
downloaded := int(data["downloaded"].(float64))
350-
if downloaded != 2 {
351-
t.Errorf("expected downloaded=2, got %d", downloaded)
349+
if data == nil {
350+
t.Error("expected data map in response")
351+
} else {
352+
downloaded := int(data["downloaded"].(float64))
353+
if downloaded != 2 {
354+
t.Errorf("expected downloaded=2, got %d", downloaded)
355+
}
352356
}
353357
})
354358

@@ -388,12 +392,14 @@ func TestCardAttachmentsDownload(t *testing.T) {
388392

389393
// Check response shows custom saved_to path
390394
data := result.GetDataMap()
391-
files := data["files"].([]any)
392-
if len(files) > 0 {
393-
fileInfo := files[0].(map[string]any)
394-
savedTo := fileInfo["saved_to"].(string)
395-
if savedTo != customFilename {
396-
t.Errorf("expected saved_to=%q, got %q", customFilename, savedTo)
395+
if data == nil {
396+
t.Error("expected data map in response")
397+
} else if files, ok := data["files"].([]any); ok && len(files) > 0 {
398+
if fileInfo, ok := files[0].(map[string]any); ok {
399+
savedTo, _ := fileInfo["saved_to"].(string)
400+
if savedTo != customFilename {
401+
t.Errorf("expected saved_to=%q, got %q", customFilename, savedTo)
402+
}
397403
}
398404
}
399405
})

e2e/tests/auth_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ func TestAuthLogin(t *testing.T) {
113113
t.Run("saves token to config file", func(t *testing.T) {
114114
// Run login with HOME set to temp directory
115115
result := harness.Execute(cfg.BinaryPath, []string{"auth", "login", cfg.Token}, map[string]string{
116-
"HOME": tmpDir,
116+
"HOME": tmpDir,
117+
"FIZZY_PROFILE": cfg.Account,
118+
"FIZZY_NO_KEYRING": "1",
117119
})
118120

119121
if result.ExitCode != harness.ExitSuccess {
@@ -153,7 +155,9 @@ func TestAuthLogout(t *testing.T) {
153155

154156
t.Run("removes config file on logout", func(t *testing.T) {
155157
result := harness.Execute(cfg.BinaryPath, []string{"auth", "logout"}, map[string]string{
156-
"HOME": tmpDir,
158+
"HOME": tmpDir,
159+
"FIZZY_PROFILE": cfg.Account,
160+
"FIZZY_NO_KEYRING": "1",
157161
})
158162

159163
if result.ExitCode != harness.ExitSuccess {
@@ -169,7 +173,9 @@ func TestAuthLogout(t *testing.T) {
169173
t.Run("succeeds when already logged out", func(t *testing.T) {
170174
// Config file already removed, run logout again
171175
result := harness.Execute(cfg.BinaryPath, []string{"auth", "logout"}, map[string]string{
172-
"HOME": tmpDir,
176+
"HOME": tmpDir,
177+
"FIZZY_PROFILE": cfg.Account,
178+
"FIZZY_NO_KEYRING": "1",
173179
})
174180

175181
if result.ExitCode != harness.ExitSuccess {

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.26
44

55
require (
66
github.com/basecamp/cli v0.1.1
7+
github.com/basecamp/fizzy-sdk/go v0.1.0
78
github.com/charmbracelet/huh v1.0.0
89
github.com/charmbracelet/lipgloss v1.1.0
910
github.com/charmbracelet/x/term v0.2.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
1010
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
1111
github.com/basecamp/cli v0.1.1 h1:FAF3M09xo1m7gJJXf38glCkT50ZUuvz+31f+c3R3zcc=
1212
github.com/basecamp/cli v0.1.1/go.mod h1:NTHe+keCTGI2qM5sMXdkUN0QgU3zGbwnBxcmg8vD5QU=
13+
github.com/basecamp/fizzy-sdk/go v0.1.0 h1:mZaFrOUnbPzaY/5NTWHahjc7p+yVLMvcNFICfa2eARw=
14+
github.com/basecamp/fizzy-sdk/go v0.1.0/go.mod h1:HpiKRY9LpWobnxISmFM0I6/Tw+Br00mnDm9vcct8IH8=
1315
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
1416
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
1517
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=

internal/commands/attachment.go

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"path/filepath"
67
"regexp"
@@ -44,30 +45,29 @@ Use --include-comments to also include attachments from comments on the card.`,
4445
return err
4546
}
4647

47-
client := getClient()
48-
resp, err := client.Get("/cards/" + args[0] + ".json")
48+
ac := getSDK()
49+
cardData, _, err := ac.Cards().Get(cmd.Context(), args[0])
4950
if err != nil {
50-
return err
51+
return convertSDKError(err)
5152
}
5253

53-
cardData, ok := resp.Data.(map[string]any)
54-
if !ok {
54+
cardMap := toMap(cardData)
55+
if cardMap == nil {
5556
return errors.NewError("Invalid card response")
5657
}
5758

58-
descriptionHTML, _ := cardData["description_html"].(string)
59+
descriptionHTML, _ := cardMap["description_html"].(string)
5960
attachments := parseAttachments(descriptionHTML)
6061

6162
if attachmentsShowIncludeComments {
62-
commentsResp, err := client.GetWithPagination("/cards/"+args[0]+"/comments.json", true)
63+
pages, err := ac.GetAll(cmd.Context(), "/cards/"+args[0]+"/comments.json")
6364
if err == nil {
64-
if comments, ok := commentsResp.Data.([]any); ok {
65-
commentAttachments := extractCommentAttachments(comments)
66-
// Re-index and append
67-
for _, ca := range commentAttachments {
68-
ca.Index = len(attachments) + 1
69-
attachments = append(attachments, ca.Attachment)
70-
}
65+
comments := rawPagesToSlice(pages)
66+
commentAttachments := extractCommentAttachments(comments)
67+
// Re-index and append
68+
for _, ca := range commentAttachments {
69+
ca.Index = len(attachments) + 1
70+
attachments = append(attachments, ca.Attachment)
7171
}
7272
}
7373
}
@@ -100,29 +100,28 @@ Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and t
100100

101101
cardNumber := args[0]
102102

103-
client := getClient()
104-
resp, err := client.Get("/cards/" + cardNumber + ".json")
103+
ac := getSDK()
104+
cardData, _, err := ac.Cards().Get(cmd.Context(), cardNumber)
105105
if err != nil {
106-
return err
106+
return convertSDKError(err)
107107
}
108108

109-
cardData, ok := resp.Data.(map[string]any)
110-
if !ok {
109+
cardMap := toMap(cardData)
110+
if cardMap == nil {
111111
return errors.NewError("Invalid card response")
112112
}
113113

114-
descriptionHTML, _ := cardData["description_html"].(string)
114+
descriptionHTML, _ := cardMap["description_html"].(string)
115115
attachments := parseAttachments(descriptionHTML)
116116

117117
if attachmentsDownloadIncludeComments {
118-
commentsResp, err := client.GetWithPagination("/cards/"+cardNumber+"/comments.json", true)
118+
pages, err := ac.GetAll(cmd.Context(), "/cards/"+cardNumber+"/comments.json")
119119
if err == nil {
120-
if comments, ok := commentsResp.Data.([]any); ok {
121-
commentAttachments := extractCommentAttachments(comments)
122-
for _, ca := range commentAttachments {
123-
ca.Index = len(attachments) + 1
124-
attachments = append(attachments, ca.Attachment)
125-
}
120+
comments := rawPagesToSlice(pages)
121+
commentAttachments := extractCommentAttachments(comments)
122+
for _, ca := range commentAttachments {
123+
ca.Index = len(attachments) + 1
124+
attachments = append(attachments, ca.Attachment)
126125
}
127126
}
128127
}
@@ -148,7 +147,8 @@ Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and t
148147
toDownload = attachments
149148
}
150149

151-
// Download the files
150+
// Download the files (uses old client for DownloadFile)
151+
client := getClient()
152152
results := make([]map[string]any, 0, len(toDownload))
153153
for i, attachment := range toDownload {
154154
outputPath := buildOutputPath(attachmentDownloadOutput, attachment.Filename, i+1, len(toDownload))
@@ -172,6 +172,18 @@ Use 'fizzy card attachments show CARD_NUMBER' to see available attachments and t
172172
},
173173
}
174174

175+
// rawPagesToSlice converts []json.RawMessage to []any (each element is a map[string]any).
176+
func rawPagesToSlice(pages []json.RawMessage) []any {
177+
result := make([]any, 0, len(pages))
178+
for _, raw := range pages {
179+
var v any
180+
if json.Unmarshal(raw, &v) == nil {
181+
result = append(result, v)
182+
}
183+
}
184+
return result
185+
}
186+
175187
// parseAttachments extracts attachment information from description_html
176188
func parseAttachments(html string) []Attachment {
177189
var attachments []Attachment

0 commit comments

Comments
 (0)