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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,22 @@ fizzy step delete STEP_ID --card 42
### Reactions

```bash
# List reactions on a card
fizzy reaction list --card 42

# List reactions on a comment
fizzy reaction list --card 42 --comment COMMENT_ID

# Add a reaction (emoji, max 16 chars)
# Add a reaction to a card (max 16 chars)
fizzy reaction create --card 42 --content "👍"

# Add a reaction to a comment (max 16 chars)
fizzy reaction create --card 42 --comment COMMENT_ID --content "👍"

# Remove a reaction
# Remove a reaction from a card
fizzy reaction delete REACTION_ID --card 42

# Remove a reaction from a comment
fizzy reaction delete REACTION_ID --card 42 --comment COMMENT_ID
```

Expand Down
23 changes: 18 additions & 5 deletions e2e/harness/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,23 +88,36 @@ func (c *CleanupTracker) AddStep(id string, cardNumber int) {
c.Steps = append(c.Steps, StepRef{ID: id, CardNumber: cardNumber})
}

// AddReaction adds a reaction to the cleanup list.
// AddReaction adds a comment reaction to the cleanup list.
func (c *CleanupTracker) AddReaction(id string, cardNumber int, commentID string) {
c.Reactions = append(c.Reactions, ReactionRef{ID: id, CardNumber: cardNumber, CommentID: commentID})
}

// AddCardReaction adds a card reaction to the cleanup list.
func (c *CleanupTracker) AddCardReaction(id string, cardNumber int) {
c.Reactions = append(c.Reactions, ReactionRef{ID: id, CardNumber: cardNumber, CommentID: ""})
}

// CleanupAll deletes all tracked resources in reverse dependency order.
// It uses the provided harness to execute delete commands.
func (c *CleanupTracker) CleanupAll(h *Harness) []error {
var errors []error

// Delete in reverse dependency order:
// 1. Reactions (depend on comments)
// 1. Reactions (depend on comments or cards)
for i := len(c.Reactions) - 1; i >= 0; i-- {
ref := c.Reactions[i]
result := h.Run("reaction", "delete", ref.ID,
"--card", strconv.Itoa(ref.CardNumber),
"--comment", ref.CommentID)
var result *Result
if ref.CommentID != "" {
// Comment reaction
result = h.Run("reaction", "delete", ref.ID,
"--card", strconv.Itoa(ref.CardNumber),
"--comment", ref.CommentID)
} else {
// Card reaction
result = h.Run("reaction", "delete", ref.ID,
"--card", strconv.Itoa(ref.CardNumber))
}
if result.ExitCode != 0 && result.ExitCode != ExitNotFound {
errors = append(errors, fmt.Errorf("failed to delete reaction %s: exit %d", ref.ID, result.ExitCode))
}
Expand Down
118 changes: 116 additions & 2 deletions e2e/tests/reaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,41 @@ func TestReactionList(t *testing.T) {
}
})

t.Run("fails without --comment option", func(t *testing.T) {
}

// TestCardReactionList tests listing reactions directly on a card (no --comment flag)
func TestCardReactionList(t *testing.T) {
h := harness.New(t)
defer h.Cleanup.CleanupAll(h)

boardID := createTestBoard(t, h)
cardNumber := createTestCard(t, h, boardID)
cardStr := strconv.Itoa(cardNumber)

t.Run("returns list of reactions for card", func(t *testing.T) {
result := h.Run("reaction", "list", "--card", cardStr)

if result.ExitCode != harness.ExitSuccess {
t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr)
}

if result.Response == nil {
t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout)
}

if !result.Response.Success {
t.Error("expected success=true")
}

arr := result.GetDataArray()
if arr == nil {
t.Error("expected data to be an array")
}
})

t.Run("fails without --card option", func(t *testing.T) {
result := h.Run("reaction", "list")

if result.ExitCode == harness.ExitSuccess {
t.Error("expected non-zero exit code for missing required option")
}
Expand Down Expand Up @@ -162,11 +194,93 @@ func TestReactionCreateMissingContent(t *testing.T) {
commentID := createTestComment(t, h, cardNumber)
cardStr := strconv.Itoa(cardNumber)

t.Run("fails without --content option", func(t *testing.T) {
t.Run("fails without --content option for comment reaction", func(t *testing.T) {
result := h.Run("reaction", "create", "--card", cardStr, "--comment", commentID)

if result.ExitCode == harness.ExitSuccess {
t.Error("expected non-zero exit code for missing required option")
}
})

t.Run("fails without --content option for card reaction", func(t *testing.T) {
result := h.Run("reaction", "create", "--card", cardStr)

if result.ExitCode == harness.ExitSuccess {
t.Error("expected non-zero exit code for missing required option")
}
})
}

// TestCardReactionCRUD tests creating and deleting reactions directly on cards
func TestCardReactionCRUD(t *testing.T) {
h := harness.New(t)
defer h.Cleanup.CleanupAll(h)

boardID := createTestBoard(t, h)
cardNumber := createTestCard(t, h, boardID)
cardStr := strconv.Itoa(cardNumber)

var reactionID string

t.Run("create card reaction", func(t *testing.T) {
result := h.Run("reaction", "create", "--card", cardStr, "--content", "🎉")

if result.ExitCode != harness.ExitSuccess {
t.Fatalf("expected exit code %d, got %d\nstderr: %s\nstdout: %s",
harness.ExitSuccess, result.ExitCode, result.Stderr, result.Stdout)
}

if result.Response == nil {
t.Fatalf("expected JSON response, got nil\nstdout: %s", result.Stdout)
}

if !result.Response.Success {
t.Errorf("expected success=true, error: %+v", result.Response.Error)
}

// List reactions to get the ID for cleanup/deletion
listResult := h.Run("reaction", "list", "--card", cardStr)
if listResult.ExitCode == harness.ExitSuccess && listResult.Response != nil {
arr := listResult.GetDataArray()
if len(arr) > 0 {
// Get the last reaction (most recently created)
lastReaction := arr[len(arr)-1].(map[string]interface{})
if id, ok := lastReaction["id"].(string); ok {
reactionID = id
h.Cleanup.AddCardReaction(reactionID, cardNumber)
}
}
}
if reactionID == "" {
t.Log("Warning: could not get reaction ID for cleanup")
}
})

t.Run("delete card reaction", func(t *testing.T) {
if reactionID == "" {
t.Skip("no reaction ID from create test")
}

result := h.Run("reaction", "delete", reactionID, "--card", cardStr)

if result.ExitCode != harness.ExitSuccess {
t.Errorf("expected exit code %d, got %d\nstderr: %s", harness.ExitSuccess, result.ExitCode, result.Stderr)
}

if result.Response == nil {
t.Fatal("expected JSON response")
}

if !result.Response.Success {
t.Error("expected success=true")
}

deleted := result.GetDataBool("deleted")
if !deleted {
t.Error("expected deleted=true")
}

// Remove from cleanup since we deleted it
h.Cleanup.Reactions = nil
})
}
62 changes: 41 additions & 21 deletions internal/commands/reaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
var reactionCmd = &cobra.Command{
Use: "reaction",
Short: "Manage reactions",
Long: "Commands for managing comment reactions.",
Long: "Commands for managing reactions on cards and comments.",
}

// Reaction list flags
Expand All @@ -18,8 +18,8 @@ var reactionListComment string

var reactionListCmd = &cobra.Command{
Use: "list",
Short: "List reactions for a comment",
Long: "Lists all reactions for a specific comment.",
Short: "List reactions",
Long: "Lists reactions on a card, or on a comment if --comment is provided.",
Run: func(cmd *cobra.Command, args []string) {
if err := requireAuthAndAccount(); err != nil {
exitWithError(err)
Expand All @@ -28,12 +28,17 @@ var reactionListCmd = &cobra.Command{
if reactionListCard == "" {
exitWithError(newRequiredFlagError("card"))
}
if reactionListComment == "" {
exitWithError(newRequiredFlagError("comment"))

// Build URL based on whether --comment was provided
var path string
if reactionListComment != "" {
path = "/cards/" + reactionListCard + "/comments/" + reactionListComment + "/reactions.json"
} else {
path = "/cards/" + reactionListCard + "/reactions.json"
}

client := getClient()
resp, err := client.Get("/cards/" + reactionListCard + "/comments/" + reactionListComment + "/reactions.json")
resp, err := client.Get(path)
if err != nil {
exitWithError(err)
}
Expand All @@ -43,7 +48,12 @@ var reactionListCmd = &cobra.Command{
if arr, ok := resp.Data.([]interface{}); ok {
count = len(arr)
}
summary := fmt.Sprintf("%d reactions on comment", count)
var summary string
if reactionListComment != "" {
summary = fmt.Sprintf("%d reactions on comment", count)
} else {
summary = fmt.Sprintf("%d reactions on card #%s", count, reactionListCard)
}

printSuccessWithSummary(resp.Data, summary)
},
Expand All @@ -56,8 +66,8 @@ var reactionCreateContent string

var reactionCreateCmd = &cobra.Command{
Use: "create",
Short: "Add a reaction to a comment",
Long: "Adds an emoji reaction to a comment.",
Short: "Add a reaction",
Long: "Adds a reaction to a card, or to a comment if --comment is provided.",
Run: func(cmd *cobra.Command, args []string) {
if err := requireAuthAndAccount(); err != nil {
exitWithError(err)
Expand All @@ -66,9 +76,6 @@ var reactionCreateCmd = &cobra.Command{
if reactionCreateCard == "" {
exitWithError(newRequiredFlagError("card"))
}
if reactionCreateComment == "" {
exitWithError(newRequiredFlagError("comment"))
}
if reactionCreateContent == "" {
exitWithError(newRequiredFlagError("content"))
}
Expand All @@ -77,8 +84,16 @@ var reactionCreateCmd = &cobra.Command{
"content": reactionCreateContent,
}

// Build URL based on whether --comment was provided
var path string
if reactionCreateComment != "" {
path = "/cards/" + reactionCreateCard + "/comments/" + reactionCreateComment + "/reactions.json"
} else {
path = "/cards/" + reactionCreateCard + "/reactions.json"
}

client := getClient()
resp, err := client.Post("/cards/"+reactionCreateCard+"/comments/"+reactionCreateComment+"/reactions.json", body)
resp, err := client.Post(path, body)
if err != nil {
exitWithError(err)
}
Expand All @@ -99,7 +114,7 @@ var reactionDeleteComment string
var reactionDeleteCmd = &cobra.Command{
Use: "delete REACTION_ID",
Short: "Remove a reaction",
Long: "Removes a reaction from a comment.",
Long: "Removes a reaction from a card, or from a comment if --comment is provided.",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if err := requireAuthAndAccount(); err != nil {
Expand All @@ -109,12 +124,17 @@ var reactionDeleteCmd = &cobra.Command{
if reactionDeleteCard == "" {
exitWithError(newRequiredFlagError("card"))
}
if reactionDeleteComment == "" {
exitWithError(newRequiredFlagError("comment"))

// Build URL based on whether --comment was provided
var path string
if reactionDeleteComment != "" {
path = "/cards/" + reactionDeleteCard + "/comments/" + reactionDeleteComment + "/reactions/" + args[0] + ".json"
} else {
path = "/cards/" + reactionDeleteCard + "/reactions/" + args[0] + ".json"
}

client := getClient()
_, err := client.Delete("/cards/" + reactionDeleteCard + "/comments/" + reactionDeleteComment + "/reactions/" + args[0] + ".json")
_, err := client.Delete(path)
if err != nil {
exitWithError(err)
}
Expand All @@ -130,17 +150,17 @@ func init() {

// List
reactionListCmd.Flags().StringVar(&reactionListCard, "card", "", "Card number (required)")
reactionListCmd.Flags().StringVar(&reactionListComment, "comment", "", "Comment ID (required)")
reactionListCmd.Flags().StringVar(&reactionListComment, "comment", "", "Comment ID (optional, for comment reactions)")
reactionCmd.AddCommand(reactionListCmd)

// Create
reactionCreateCmd.Flags().StringVar(&reactionCreateCard, "card", "", "Card number (required)")
reactionCreateCmd.Flags().StringVar(&reactionCreateComment, "comment", "", "Comment ID (required)")
reactionCreateCmd.Flags().StringVar(&reactionCreateContent, "content", "", "Emoji content (required)")
reactionCreateCmd.Flags().StringVar(&reactionCreateComment, "comment", "", "Comment ID (optional, for comment reactions)")
reactionCreateCmd.Flags().StringVar(&reactionCreateContent, "content", "", "Reaction content (required)")
reactionCmd.AddCommand(reactionCreateCmd)

// Delete
reactionDeleteCmd.Flags().StringVar(&reactionDeleteCard, "card", "", "Card number (required)")
reactionDeleteCmd.Flags().StringVar(&reactionDeleteComment, "comment", "", "Comment ID (required)")
reactionDeleteCmd.Flags().StringVar(&reactionDeleteComment, "comment", "", "Comment ID (optional, for comment reactions)")
reactionCmd.AddCommand(reactionDeleteCmd)
}
Loading