Skip to content

Commit d1d4eb9

Browse files
authored
Merge pull request basecamp#128 from basecamp/feature/attachment-ergonomics
Add repeatable --attach flags for card and comment rich text
2 parents 77ea47b + 1d4c482 commit d1d4eb9

14 files changed

Lines changed: 872 additions & 69 deletions

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@ fizzy search "authentication" # Search across cards
9494
fizzy comment create --card 42 --body "Looks good!" # Add comment
9595
```
9696

97+
### Attachments
98+
99+
Simple mode uses repeatable `--attach` and appends inline attachments to the end of card descriptions or comment bodies:
100+
101+
```bash
102+
fizzy card create --board ID --title "Bug report" --description "See attached" --attach screenshot.png
103+
fizzy comment create --card 42 --attach logs.txt
104+
fizzy comment create --card 42 --body_file comment.md --attach screenshot.png --attach trace.txt
105+
```
106+
107+
Advanced mode still works when exact placement matters:
108+
109+
```bash
110+
SGID=$(fizzy upload file screenshot.png --jq '.data.attachable_sgid')
111+
fizzy card create --board ID --title "Bug report" \
112+
--description "<p>See screenshot</p><action-text-attachment sgid=\"$SGID\"></action-text-attachment>"
113+
```
114+
115+
Use `signed_id` from `fizzy upload file` only for card header images via `--image`.
116+
97117
### Output Formats
98118

99119
```bash

SURFACE.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,7 @@ FLAG fizzy card column --token type=string
893893
FLAG fizzy card column --verbose type=bool
894894
FLAG fizzy card create --agent type=bool
895895
FLAG fizzy card create --api-url type=string
896+
FLAG fizzy card create --attach type=stringArray
896897
FLAG fizzy card create --board type=string
897898
FLAG fizzy card create --count type=bool
898899
FLAG fizzy card create --created-at type=string
@@ -1237,6 +1238,7 @@ FLAG fizzy card unwatch --token type=string
12371238
FLAG fizzy card unwatch --verbose type=bool
12381239
FLAG fizzy card update --agent type=bool
12391240
FLAG fizzy card update --api-url type=string
1241+
FLAG fizzy card update --attach type=stringArray
12401242
FLAG fizzy card update --count type=bool
12411243
FLAG fizzy card update --created-at type=string
12421244
FLAG fizzy card update --description type=string
@@ -1580,6 +1582,7 @@ FLAG fizzy comment attachments view --token type=string
15801582
FLAG fizzy comment attachments view --verbose type=bool
15811583
FLAG fizzy comment create --agent type=bool
15821584
FLAG fizzy comment create --api-url type=string
1585+
FLAG fizzy comment create --attach type=stringArray
15831586
FLAG fizzy comment create --body type=string
15841587
FLAG fizzy comment create --body_file type=string
15851588
FLAG fizzy comment create --card type=string
@@ -1691,6 +1694,7 @@ FLAG fizzy comment show --token type=string
16911694
FLAG fizzy comment show --verbose type=bool
16921695
FLAG fizzy comment update --agent type=bool
16931696
FLAG fizzy comment update --api-url type=string
1697+
FLAG fizzy comment update --attach type=stringArray
16941698
FLAG fizzy comment update --body type=string
16951699
FLAG fizzy comment update --body_file type=string
16961700
FLAG fizzy comment update --card type=string
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package clitests
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strconv"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestCardAttachFlag(t *testing.T) {
13+
h := newHarness(t)
14+
boardID := createBoard(t, h)
15+
imagePath := fixtureFile(t, "test_image.png")
16+
docPath := fixtureFile(t, "test_document.txt")
17+
18+
t.Run("creates card with single attach and downloads it", func(t *testing.T) {
19+
title := fmt.Sprintf("Attach Flag Card %d", time.Now().UnixNano())
20+
result := h.Run("card", "create", "--board", boardID, "--title", title, "--attach", imagePath)
21+
assertOK(t, result)
22+
23+
cardNumber := result.GetNumberFromLocation()
24+
if cardNumber == 0 {
25+
cardNumber = result.GetDataInt("number")
26+
}
27+
if cardNumber == 0 {
28+
t.Fatalf("failed to get card number from create (location: %s)", result.GetLocation())
29+
}
30+
31+
showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber))
32+
assertOK(t, showResult)
33+
arr := showResult.GetDataArray()
34+
if len(arr) != 1 {
35+
t.Fatalf("expected 1 attachment, got %d", len(arr))
36+
}
37+
attachment := asMap(arr[0])
38+
if got := mapValueString(attachment, "filename"); got != "test_image.png" {
39+
t.Fatalf("expected filename test_image.png, got %v", got)
40+
}
41+
42+
outputPath := filepath.Join(t.TempDir(), "test_image.png")
43+
downloadResult := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "1", "-o", outputPath)
44+
assertOK(t, downloadResult)
45+
assertFileExists(t, outputPath)
46+
})
47+
48+
t.Run("creates card with multiple attaches in order", func(t *testing.T) {
49+
title := fmt.Sprintf("Attach Flag Multi Card %d", time.Now().UnixNano())
50+
result := h.Run(
51+
"card", "create",
52+
"--board", boardID,
53+
"--title", title,
54+
"--description", "See attached files",
55+
"--attach", imagePath,
56+
"--attach", docPath,
57+
)
58+
assertOK(t, result)
59+
60+
cardNumber := result.GetNumberFromLocation()
61+
if cardNumber == 0 {
62+
cardNumber = result.GetDataInt("number")
63+
}
64+
65+
showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber))
66+
assertOK(t, showResult)
67+
arr := showResult.GetDataArray()
68+
if len(arr) != 2 {
69+
t.Fatalf("expected 2 attachments, got %d", len(arr))
70+
}
71+
if got := mapValueString(asMap(arr[0]), "filename"); got != "test_image.png" {
72+
t.Fatalf("expected first attachment test_image.png, got %v", got)
73+
}
74+
if got := mapValueString(asMap(arr[1]), "filename"); got != "test_document.txt" {
75+
t.Fatalf("expected second attachment test_document.txt, got %v", got)
76+
}
77+
})
78+
79+
t.Run("works with description_file", func(t *testing.T) {
80+
descriptionFile := filepath.Join(t.TempDir(), "description.md")
81+
mustWriteFile(t, descriptionFile, []byte("See file-based content"))
82+
83+
title := fmt.Sprintf("Attach Flag File Card %d", time.Now().UnixNano())
84+
result := h.Run(
85+
"card", "create",
86+
"--board", boardID,
87+
"--title", title,
88+
"--description_file", descriptionFile,
89+
"--attach", imagePath,
90+
)
91+
assertOK(t, result)
92+
93+
cardNumber := result.GetNumberFromLocation()
94+
if cardNumber == 0 {
95+
cardNumber = result.GetDataInt("number")
96+
}
97+
98+
showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber))
99+
assertOK(t, showResult)
100+
if got := len(showResult.GetDataArray()); got != 1 {
101+
t.Fatalf("expected 1 attachment from description_file flow, got %d", got)
102+
}
103+
})
104+
}
105+
106+
func assertFileExists(t *testing.T, path string) {
107+
t.Helper()
108+
if _, err := os.Stat(path); err != nil {
109+
t.Fatalf("expected file at %s: %v", path, err)
110+
}
111+
}
112+
113+
func mustWriteFile(t *testing.T, path string, content []byte) {
114+
t.Helper()
115+
if err := os.WriteFile(path, content, 0o644); err != nil {
116+
t.Fatalf("failed to write %s: %v", path, err)
117+
}
118+
}
119+
120+
func TestCommentAttachFlag(t *testing.T) {
121+
h := newHarness(t)
122+
cardNumber := createCard(t, h, fixture.BoardID)
123+
cardStr := strconv.Itoa(cardNumber)
124+
imagePath := fixtureFile(t, "test_image.png")
125+
docPath := fixtureFile(t, "test_document.txt")
126+
127+
t.Run("creates attachment-only comment with single attach", func(t *testing.T) {
128+
before := h.Run("comment", "attachments", "show", "--card", cardStr)
129+
assertOK(t, before)
130+
beforeCount := len(before.GetDataArray())
131+
132+
result := h.Run("comment", "create", "--card", cardStr, "--attach", imagePath)
133+
assertOK(t, result)
134+
135+
showResult := h.Run("comment", "attachments", "show", "--card", cardStr)
136+
assertOK(t, showResult)
137+
arr := showResult.GetDataArray()
138+
if len(arr) != beforeCount+1 {
139+
t.Fatalf("expected %d comment attachments, got %d", beforeCount+1, len(arr))
140+
}
141+
added := asMap(arr[len(arr)-1])
142+
if got := mapValueString(added, "filename"); got != "test_image.png" {
143+
t.Fatalf("expected filename test_image.png, got %v", got)
144+
}
145+
146+
outputPath := filepath.Join(t.TempDir(), "test_image.png")
147+
downloadResult := h.Run("comment", "attachments", "download", "--card", cardStr, strconv.Itoa(len(arr)), "-o", outputPath)
148+
assertOK(t, downloadResult)
149+
assertFileExists(t, outputPath)
150+
})
151+
152+
t.Run("creates comment with multiple attaches in order", func(t *testing.T) {
153+
before := h.Run("comment", "attachments", "show", "--card", cardStr)
154+
assertOK(t, before)
155+
beforeCount := len(before.GetDataArray())
156+
157+
result := h.Run(
158+
"comment", "create",
159+
"--card", cardStr,
160+
"--body", "See attached files",
161+
"--attach", imagePath,
162+
"--attach", docPath,
163+
)
164+
assertOK(t, result)
165+
166+
showResult := h.Run("comment", "attachments", "show", "--card", cardStr)
167+
assertOK(t, showResult)
168+
arr := showResult.GetDataArray()
169+
if len(arr) != beforeCount+2 {
170+
t.Fatalf("expected %d comment attachments, got %d", beforeCount+2, len(arr))
171+
}
172+
173+
lastTwo := arr[len(arr)-2:]
174+
if got := mapValueString(asMap(lastTwo[0]), "filename"); got != "test_image.png" {
175+
t.Fatalf("expected first new attachment test_image.png, got %v", got)
176+
}
177+
if got := mapValueString(asMap(lastTwo[1]), "filename"); got != "test_document.txt" {
178+
t.Fatalf("expected second new attachment test_document.txt, got %v", got)
179+
}
180+
})
181+
}

internal/commands/card.go

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package commands
22

33
import (
44
"fmt"
5-
"os"
65
"strconv"
76
"strings"
87

@@ -266,13 +265,14 @@ var cardCreateBoard string
266265
var cardCreateTitle string
267266
var cardCreateDescription string
268267
var cardCreateDescriptionFile string
268+
var cardCreateAttach []string
269269
var cardCreateImage string
270270
var cardCreateCreatedAt string
271271

272272
var cardCreateCmd = &cobra.Command{
273273
Use: "create",
274274
Short: "Create a card",
275-
Long: "Creates a new card in a board.",
275+
Long: "Creates a new card in a board. Use --attach for simple end-appended inline attachments. For precise placement, upload files first and embed <action-text-attachment> tags manually in --description or --description_file.",
276276
RunE: func(cmd *cobra.Command, args []string) error {
277277
if err := requireAuthAndAccount(); err != nil {
278278
return err
@@ -286,16 +286,13 @@ var cardCreateCmd = &cobra.Command{
286286
return newRequiredFlagError("title")
287287
}
288288

289-
// Resolve description
290-
var description string
291-
if cardCreateDescriptionFile != "" {
292-
descContent, descErr := os.ReadFile(cardCreateDescriptionFile)
293-
if descErr != nil {
294-
return descErr
295-
}
296-
description = markdownToHTML(string(descContent))
297-
} else if cardCreateDescription != "" {
298-
description = markdownToHTML(cardCreateDescription)
289+
description, err := resolveRichTextContent(cardCreateDescription, cardCreateDescriptionFile)
290+
if err != nil {
291+
return err
292+
}
293+
description, err = appendInlineAttachmentsToContent(description, cardCreateAttach)
294+
if err != nil {
295+
return err
299296
}
300297

301298
ac := getSDK()
@@ -362,13 +359,14 @@ var cardCreateCmd = &cobra.Command{
362359
var cardUpdateTitle string
363360
var cardUpdateDescription string
364361
var cardUpdateDescriptionFile string
362+
var cardUpdateAttach []string
365363
var cardUpdateImage string
366364
var cardUpdateCreatedAt string
367365

368366
var cardUpdateCmd = &cobra.Command{
369367
Use: "update CARD_NUMBER",
370368
Short: "Update a card",
371-
Long: "Updates an existing card.",
369+
Long: "Updates an existing card. Use --attach for simple end-appended inline attachments. For precise placement, upload files first and embed <action-text-attachment> tags manually in --description or --description_file.",
372370
Args: cobra.ExactArgs(1),
373371
RunE: func(cmd *cobra.Command, args []string) error {
374372
if err := requireAuthAndAccount(); err != nil {
@@ -377,16 +375,25 @@ var cardUpdateCmd = &cobra.Command{
377375

378376
cardNumber := args[0]
379377

380-
// Resolve description
381-
var description string
382-
if cardUpdateDescriptionFile != "" {
383-
content, err := os.ReadFile(cardUpdateDescriptionFile)
384-
if err != nil {
385-
return err
378+
hasDescriptionInput := cardUpdateDescription != "" || cardUpdateDescriptionFile != ""
379+
description, err := resolveRichTextContent(cardUpdateDescription, cardUpdateDescriptionFile)
380+
if err != nil {
381+
return err
382+
}
383+
if len(cardUpdateAttach) > 0 && !hasDescriptionInput {
384+
currentData, _, getErr := getSDK().Cards().Get(cmd.Context(), cardNumber)
385+
if getErr != nil {
386+
return convertSDKError(getErr)
387+
}
388+
if current, ok := normalizeAny(currentData).(map[string]any); ok {
389+
if currentDescription, ok := current["description_html"].(string); ok {
390+
description = currentDescription
391+
}
386392
}
387-
description = markdownToHTML(string(content))
388-
} else if cardUpdateDescription != "" {
389-
description = markdownToHTML(cardUpdateDescription)
393+
}
394+
description, err = appendInlineAttachmentsToContent(description, cardUpdateAttach)
395+
if err != nil {
396+
return err
390397
}
391398

392399
// Build breadcrumbs
@@ -1098,16 +1105,18 @@ func init() {
10981105
// Create
10991106
cardCreateCmd.Flags().StringVar(&cardCreateBoard, "board", "", "Board ID (required)")
11001107
cardCreateCmd.Flags().StringVar(&cardCreateTitle, "title", "", "Card title (required)")
1101-
cardCreateCmd.Flags().StringVar(&cardCreateDescription, "description", "", "Card description (HTML)")
1102-
cardCreateCmd.Flags().StringVar(&cardCreateDescriptionFile, "description_file", "", "Read description from file")
1108+
cardCreateCmd.Flags().StringVar(&cardCreateDescription, "description", "", "Card description (markdown or HTML)")
1109+
cardCreateCmd.Flags().StringVar(&cardCreateDescriptionFile, "description_file", "", "Read description from file (markdown or HTML)")
1110+
cardCreateCmd.Flags().StringArrayVar(&cardCreateAttach, "attach", nil, "Upload and append inline attachment at the end of the description. Repeatable.")
11031111
cardCreateCmd.Flags().StringVar(&cardCreateImage, "image", "", "Header image signed ID")
11041112
cardCreateCmd.Flags().StringVar(&cardCreateCreatedAt, "created-at", "", "Custom created_at timestamp")
11051113
cardCmd.AddCommand(cardCreateCmd)
11061114

11071115
// Update
11081116
cardUpdateCmd.Flags().StringVar(&cardUpdateTitle, "title", "", "Card title")
1109-
cardUpdateCmd.Flags().StringVar(&cardUpdateDescription, "description", "", "Card description (HTML)")
1110-
cardUpdateCmd.Flags().StringVar(&cardUpdateDescriptionFile, "description_file", "", "Read description from file")
1117+
cardUpdateCmd.Flags().StringVar(&cardUpdateDescription, "description", "", "Card description (markdown or HTML)")
1118+
cardUpdateCmd.Flags().StringVar(&cardUpdateDescriptionFile, "description_file", "", "Read description from file (markdown or HTML)")
1119+
cardUpdateCmd.Flags().StringArrayVar(&cardUpdateAttach, "attach", nil, "Upload and append inline attachment at the end of the description. Repeatable.")
11111120
cardUpdateCmd.Flags().StringVar(&cardUpdateImage, "image", "", "Header image signed ID")
11121121
cardUpdateCmd.Flags().StringVar(&cardUpdateCreatedAt, "created-at", "", "Custom created_at timestamp")
11131122
cardCmd.AddCommand(cardUpdateCmd)

0 commit comments

Comments
 (0)