Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ fizzy search "authentication" # Search across cards
fizzy comment create --card 42 --body "Looks good!" # Add comment
```

### Attachments

Simple mode uses repeatable `--attach` and appends inline attachments to the end of card descriptions or comment bodies:

```bash
fizzy card create --board ID --title "Bug report" --description "See attached" --attach screenshot.png
fizzy comment create --card 42 --attach logs.txt
fizzy comment create --card 42 --body_file comment.md --attach screenshot.png --attach trace.txt
```

Advanced mode still works when exact placement matters:

```bash
SGID=$(fizzy upload file screenshot.png --jq '.data.attachable_sgid')
fizzy card create --board ID --title "Bug report" \
--description "<p>See screenshot</p><action-text-attachment sgid=\"$SGID\"></action-text-attachment>"
```

Use `signed_id` from `fizzy upload file` only for card header images via `--image`.

### Output Formats

```bash
Expand Down
4 changes: 4 additions & 0 deletions SURFACE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,7 @@ FLAG fizzy card column --token type=string
FLAG fizzy card column --verbose type=bool
FLAG fizzy card create --agent type=bool
FLAG fizzy card create --api-url type=string
FLAG fizzy card create --attach type=stringArray
FLAG fizzy card create --board type=string
FLAG fizzy card create --count type=bool
FLAG fizzy card create --created-at type=string
Expand Down Expand Up @@ -1237,6 +1238,7 @@ FLAG fizzy card unwatch --token type=string
FLAG fizzy card unwatch --verbose type=bool
FLAG fizzy card update --agent type=bool
FLAG fizzy card update --api-url type=string
FLAG fizzy card update --attach type=stringArray
FLAG fizzy card update --count type=bool
FLAG fizzy card update --created-at type=string
FLAG fizzy card update --description type=string
Expand Down Expand Up @@ -1580,6 +1582,7 @@ FLAG fizzy comment attachments view --token type=string
FLAG fizzy comment attachments view --verbose type=bool
FLAG fizzy comment create --agent type=bool
FLAG fizzy comment create --api-url type=string
FLAG fizzy comment create --attach type=stringArray
FLAG fizzy comment create --body type=string
FLAG fizzy comment create --body_file type=string
FLAG fizzy comment create --card type=string
Expand Down Expand Up @@ -1691,6 +1694,7 @@ FLAG fizzy comment show --token type=string
FLAG fizzy comment show --verbose type=bool
FLAG fizzy comment update --agent type=bool
FLAG fizzy comment update --api-url type=string
FLAG fizzy comment update --attach type=stringArray
FLAG fizzy comment update --body type=string
FLAG fizzy comment update --body_file type=string
FLAG fizzy comment update --card type=string
Expand Down
181 changes: 181 additions & 0 deletions e2e/cli_tests/attachment_ergonomics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package clitests

import (
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)

func TestCardAttachFlag(t *testing.T) {
h := newHarness(t)
boardID := createBoard(t, h)
imagePath := fixtureFile(t, "test_image.png")
docPath := fixtureFile(t, "test_document.txt")

t.Run("creates card with single attach and downloads it", func(t *testing.T) {
title := fmt.Sprintf("Attach Flag Card %d", time.Now().UnixNano())
result := h.Run("card", "create", "--board", boardID, "--title", title, "--attach", imagePath)
assertOK(t, result)

cardNumber := result.GetNumberFromLocation()
if cardNumber == 0 {
cardNumber = result.GetDataInt("number")
}
if cardNumber == 0 {
t.Fatalf("failed to get card number from create (location: %s)", result.GetLocation())
}

showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber))
assertOK(t, showResult)
arr := showResult.GetDataArray()
if len(arr) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(arr))
}
attachment := asMap(arr[0])
if got := mapValueString(attachment, "filename"); got != "test_image.png" {
t.Fatalf("expected filename test_image.png, got %v", got)
}

outputPath := filepath.Join(t.TempDir(), "test_image.png")
downloadResult := h.Run("card", "attachments", "download", strconv.Itoa(cardNumber), "1", "-o", outputPath)
assertOK(t, downloadResult)
assertFileExists(t, outputPath)
})

t.Run("creates card with multiple attaches in order", func(t *testing.T) {
title := fmt.Sprintf("Attach Flag Multi Card %d", time.Now().UnixNano())
result := h.Run(
"card", "create",
"--board", boardID,
"--title", title,
"--description", "See attached files",
"--attach", imagePath,
"--attach", docPath,
)
assertOK(t, result)

cardNumber := result.GetNumberFromLocation()
if cardNumber == 0 {
cardNumber = result.GetDataInt("number")
}

showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber))
assertOK(t, showResult)
arr := showResult.GetDataArray()
if len(arr) != 2 {
t.Fatalf("expected 2 attachments, got %d", len(arr))
}
if got := mapValueString(asMap(arr[0]), "filename"); got != "test_image.png" {
t.Fatalf("expected first attachment test_image.png, got %v", got)
}
if got := mapValueString(asMap(arr[1]), "filename"); got != "test_document.txt" {
t.Fatalf("expected second attachment test_document.txt, got %v", got)
}
})

t.Run("works with description_file", func(t *testing.T) {
descriptionFile := filepath.Join(t.TempDir(), "description.md")
mustWriteFile(t, descriptionFile, []byte("See file-based content"))

title := fmt.Sprintf("Attach Flag File Card %d", time.Now().UnixNano())
result := h.Run(
"card", "create",
"--board", boardID,
"--title", title,
"--description_file", descriptionFile,
"--attach", imagePath,
)
assertOK(t, result)

cardNumber := result.GetNumberFromLocation()
if cardNumber == 0 {
cardNumber = result.GetDataInt("number")
}

showResult := h.Run("card", "attachments", "show", strconv.Itoa(cardNumber))
assertOK(t, showResult)
if got := len(showResult.GetDataArray()); got != 1 {
t.Fatalf("expected 1 attachment from description_file flow, got %d", got)
}
})
}

func assertFileExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file at %s: %v", path, err)
}
}

func mustWriteFile(t *testing.T, path string, content []byte) {
t.Helper()
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("failed to write %s: %v", path, err)
}
}

func TestCommentAttachFlag(t *testing.T) {
h := newHarness(t)
cardNumber := createCard(t, h, fixture.BoardID)
cardStr := strconv.Itoa(cardNumber)
imagePath := fixtureFile(t, "test_image.png")
docPath := fixtureFile(t, "test_document.txt")

t.Run("creates attachment-only comment with single attach", func(t *testing.T) {
before := h.Run("comment", "attachments", "show", "--card", cardStr)
assertOK(t, before)
beforeCount := len(before.GetDataArray())

result := h.Run("comment", "create", "--card", cardStr, "--attach", imagePath)
assertOK(t, result)

showResult := h.Run("comment", "attachments", "show", "--card", cardStr)
assertOK(t, showResult)
arr := showResult.GetDataArray()
if len(arr) != beforeCount+1 {
t.Fatalf("expected %d comment attachments, got %d", beforeCount+1, len(arr))
}
added := asMap(arr[len(arr)-1])
if got := mapValueString(added, "filename"); got != "test_image.png" {
t.Fatalf("expected filename test_image.png, got %v", got)
}

outputPath := filepath.Join(t.TempDir(), "test_image.png")
downloadResult := h.Run("comment", "attachments", "download", "--card", cardStr, strconv.Itoa(len(arr)), "-o", outputPath)
assertOK(t, downloadResult)
assertFileExists(t, outputPath)
})

t.Run("creates comment with multiple attaches in order", func(t *testing.T) {
before := h.Run("comment", "attachments", "show", "--card", cardStr)
assertOK(t, before)
beforeCount := len(before.GetDataArray())

result := h.Run(
"comment", "create",
"--card", cardStr,
"--body", "See attached files",
"--attach", imagePath,
"--attach", docPath,
)
assertOK(t, result)

showResult := h.Run("comment", "attachments", "show", "--card", cardStr)
assertOK(t, showResult)
arr := showResult.GetDataArray()
if len(arr) != beforeCount+2 {
t.Fatalf("expected %d comment attachments, got %d", beforeCount+2, len(arr))
}

lastTwo := arr[len(arr)-2:]
if got := mapValueString(asMap(lastTwo[0]), "filename"); got != "test_image.png" {
t.Fatalf("expected first new attachment test_image.png, got %v", got)
}
if got := mapValueString(asMap(lastTwo[1]), "filename"); got != "test_document.txt" {
t.Fatalf("expected second new attachment test_document.txt, got %v", got)
}
})
}
61 changes: 35 additions & 26 deletions internal/commands/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package commands

import (
"fmt"
"os"
"strconv"
"strings"

Expand Down Expand Up @@ -266,13 +265,14 @@ var cardCreateBoard string
var cardCreateTitle string
var cardCreateDescription string
var cardCreateDescriptionFile string
var cardCreateAttach []string
var cardCreateImage string
var cardCreateCreatedAt string

var cardCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a card",
Long: "Creates a new card in a board.",
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.",
RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthAndAccount(); err != nil {
return err
Expand All @@ -286,16 +286,13 @@ var cardCreateCmd = &cobra.Command{
return newRequiredFlagError("title")
}

// Resolve description
var description string
if cardCreateDescriptionFile != "" {
descContent, descErr := os.ReadFile(cardCreateDescriptionFile)
if descErr != nil {
return descErr
}
description = markdownToHTML(string(descContent))
} else if cardCreateDescription != "" {
description = markdownToHTML(cardCreateDescription)
description, err := resolveRichTextContent(cardCreateDescription, cardCreateDescriptionFile)
if err != nil {
return err
}
description, err = appendInlineAttachmentsToContent(description, cardCreateAttach)
if err != nil {
return err
}

ac := getSDK()
Expand Down Expand Up @@ -362,13 +359,14 @@ var cardCreateCmd = &cobra.Command{
var cardUpdateTitle string
var cardUpdateDescription string
var cardUpdateDescriptionFile string
var cardUpdateAttach []string
var cardUpdateImage string
var cardUpdateCreatedAt string

var cardUpdateCmd = &cobra.Command{
Use: "update CARD_NUMBER",
Short: "Update a card",
Long: "Updates an existing card.",
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.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := requireAuthAndAccount(); err != nil {
Expand All @@ -377,16 +375,25 @@ var cardUpdateCmd = &cobra.Command{

cardNumber := args[0]

// Resolve description
var description string
if cardUpdateDescriptionFile != "" {
content, err := os.ReadFile(cardUpdateDescriptionFile)
if err != nil {
return err
hasDescriptionInput := cardUpdateDescription != "" || cardUpdateDescriptionFile != ""
description, err := resolveRichTextContent(cardUpdateDescription, cardUpdateDescriptionFile)
if err != nil {
return err
}
if len(cardUpdateAttach) > 0 && !hasDescriptionInput {
currentData, _, getErr := getSDK().Cards().Get(cmd.Context(), cardNumber)
if getErr != nil {
return convertSDKError(getErr)
}
if current, ok := normalizeAny(currentData).(map[string]any); ok {
if currentDescription, ok := current["description_html"].(string); ok {
description = currentDescription
}
}
description = markdownToHTML(string(content))
} else if cardUpdateDescription != "" {
description = markdownToHTML(cardUpdateDescription)
}
description, err = appendInlineAttachmentsToContent(description, cardUpdateAttach)
if err != nil {
return err
}
Comment thread
robzolkos marked this conversation as resolved.

// Build breadcrumbs
Expand Down Expand Up @@ -1098,16 +1105,18 @@ func init() {
// Create
cardCreateCmd.Flags().StringVar(&cardCreateBoard, "board", "", "Board ID (required)")
cardCreateCmd.Flags().StringVar(&cardCreateTitle, "title", "", "Card title (required)")
cardCreateCmd.Flags().StringVar(&cardCreateDescription, "description", "", "Card description (HTML)")
cardCreateCmd.Flags().StringVar(&cardCreateDescriptionFile, "description_file", "", "Read description from file")
cardCreateCmd.Flags().StringVar(&cardCreateDescription, "description", "", "Card description (markdown or HTML)")
cardCreateCmd.Flags().StringVar(&cardCreateDescriptionFile, "description_file", "", "Read description from file (markdown or HTML)")
cardCreateCmd.Flags().StringArrayVar(&cardCreateAttach, "attach", nil, "Upload and append inline attachment at the end of the description. Repeatable.")
Comment thread
robzolkos marked this conversation as resolved.
cardCreateCmd.Flags().StringVar(&cardCreateImage, "image", "", "Header image signed ID")
cardCreateCmd.Flags().StringVar(&cardCreateCreatedAt, "created-at", "", "Custom created_at timestamp")
cardCmd.AddCommand(cardCreateCmd)

// Update
cardUpdateCmd.Flags().StringVar(&cardUpdateTitle, "title", "", "Card title")
cardUpdateCmd.Flags().StringVar(&cardUpdateDescription, "description", "", "Card description (HTML)")
cardUpdateCmd.Flags().StringVar(&cardUpdateDescriptionFile, "description_file", "", "Read description from file")
cardUpdateCmd.Flags().StringVar(&cardUpdateDescription, "description", "", "Card description (markdown or HTML)")
cardUpdateCmd.Flags().StringVar(&cardUpdateDescriptionFile, "description_file", "", "Read description from file (markdown or HTML)")
cardUpdateCmd.Flags().StringArrayVar(&cardUpdateAttach, "attach", nil, "Upload and append inline attachment at the end of the description. Repeatable.")
cardUpdateCmd.Flags().StringVar(&cardUpdateImage, "image", "", "Header image signed ID")
cardUpdateCmd.Flags().StringVar(&cardUpdateCreatedAt, "created-at", "", "Custom created_at timestamp")
cardCmd.AddCommand(cardUpdateCmd)
Expand Down
Loading
Loading