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
15 changes: 15 additions & 0 deletions cli/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"
)
Expand All @@ -28,6 +29,7 @@ func NewCreateCmd(clientFn func() *Client, out *bytes.Buffer) *CreateCmd {
humanMode, _ := cmd.Flags().GetBool("human")
branch, _ := cmd.Flags().GetString("branch")
noBranch, _ := cmd.Flags().GetBool("no-branch")
tags, _ := cmd.Flags().GetStringSlice("tag")
branchSet := cmd.Flags().Changed("branch")
noBranchSet := cmd.Flags().Changed("no-branch")

Expand Down Expand Up @@ -58,6 +60,18 @@ func NewCreateCmd(clientFn func() *Client, out *bytes.Buffer) *CreateCmd {
} else if branchSet {
body["branch"] = branch
}
if len(tags) > 0 {
normalized := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag != "" {
normalized = append(normalized, tag)
}
}
if len(normalized) > 0 {
body["tags"] = normalized
}
}

respBody, err := clientFn().DoPost("/create-rune", body)
if err != nil {
Expand All @@ -81,6 +95,7 @@ func NewCreateCmd(clientFn func() *Client, out *bytes.Buffer) *CreateCmd {
cmd.Flags().Bool("human", false, "human-readable output")
cmd.Flags().StringP("branch", "b", "", "branch name for the rune")
cmd.Flags().Bool("no-branch", false, "create rune without a branch")
cmd.Flags().StringSlice("tag", nil, "tag to apply (repeatable)")

c.Command = cmd
return c
Expand Down
42 changes: 42 additions & 0 deletions cli/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ func TestCreateCommand(t *testing.T) {
tc.request_body_has_field("parent_id", "bf-parent-123")
})

t.Run("includes lowercase tags when --tag flags are set", func(t *testing.T) {
tc := newCreateTestContext(t)

// Given
tc.server_that_captures_request_and_returns_created()
tc.client_configured()

// When
tc.execute_create_with_tags("My Rune", "1", "Backend", " urgent ")

// Then
tc.command_has_no_error()
tc.request_body_has_array_field("tags", "backend", "urgent")
})

t.Run("outputs JSON response by default", func(t *testing.T) {
tc := newCreateTestContext(t)

Expand Down Expand Up @@ -309,6 +324,17 @@ func (tc *createTestContext) execute_create_with_branch_and_no_branch(title, pri
tc.err = cmd.Command.Execute()
}

func (tc *createTestContext) execute_create_with_tags(title, priority string, tags ...string) {
tc.t.Helper()
args := []string{title, "-p", priority, "--no-branch"}
for _, tag := range tags {
args = append(args, "--tag", tag)
}
cmd := NewCreateCmd(func() *Client { return tc.client }, tc.buf)
cmd.Command.SetArgs(args)
tc.err = cmd.Command.Execute()
}

// --- Then ---

func (tc *createTestContext) command_has_no_error() {
Expand Down Expand Up @@ -360,3 +386,19 @@ func (tc *createTestContext) error_contains(substr string) {
require.Error(tc.t, tc.err)
assert.Contains(tc.t, tc.err.Error(), substr)
}

func (tc *createTestContext) request_body_has_array_field(key string, expected ...string) {
tc.t.Helper()
require.NotNil(tc.t, tc.receivedBody)
raw, ok := tc.receivedBody[key].([]any)
require.True(tc.t, ok, "expected %q to be an array", key)
actual := make([]string, 0, len(raw))
for i, item := range raw {
s, ok := item.(string)
if !ok {
tc.t.Fatalf("expected all elements in %q to be strings, but element at index %d is %T: %v", key, i, item, item)
}
actual = append(actual, s)
}
assert.Equal(tc.t, expected, actual)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
15 changes: 15 additions & 0 deletions cli/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"
Expand All @@ -25,6 +26,7 @@ func NewListCmd(clientFn func() *Client, out *bytes.Buffer) *ListCmd {
assignee, _ := cmd.Flags().GetString("assignee")
branch, _ := cmd.Flags().GetString("branch")
saga, _ := cmd.Flags().GetString("saga")
tags, _ := cmd.Flags().GetStringSlice("tag")
humanMode, _ := cmd.Flags().GetBool("human")

params := map[string]string{}
Expand All @@ -43,6 +45,18 @@ func NewListCmd(clientFn func() *Client, out *bytes.Buffer) *ListCmd {
if saga != "" {
params["saga"] = saga
}
if len(tags) > 0 {
normalized := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag != "" {
normalized = append(normalized, tag)
}
}
if len(normalized) > 0 {
params["tags"] = strings.Join(normalized, ",")
}
}

respBody, err := clientFn().DoGetWithParams("/runes", params)
if err != nil {
Expand Down Expand Up @@ -89,6 +103,7 @@ func NewListCmd(clientFn func() *Client, out *bytes.Buffer) *ListCmd {
cmd.Flags().String("assignee", "", "filter by assignee name")
cmd.Flags().String("branch", "", "filter by branch name")
cmd.Flags().String("saga", "", "filter by parent saga ID")
cmd.Flags().StringSlice("tag", nil, "filter by tag (repeatable)")
cmd.Flags().Bool("human", false, "human-readable table output")

c.Command = cmd
Expand Down
23 changes: 23 additions & 0 deletions cli/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ func TestListCommand(t *testing.T) {
tc.request_query_param_was("branch", "feature-x")
})

t.Run("passes tag filter as tags query parameter", func(t *testing.T) {
tc := newListTestContext(t)

// Given
tc.server_that_captures_request_and_returns_runes()
tc.client_configured()

// When
tc.execute_list_with_tag("Backend")

// Then
tc.command_has_no_error()
tc.request_query_param_was("tags", "backend")
})

t.Run("omits branch query parameter when flag not set", func(t *testing.T) {
tc := newListTestContext(t)

Expand Down Expand Up @@ -274,6 +289,14 @@ func (tc *listTestContext) execute_list_with_human() {
tc.err = cmd.Command.Execute()
}

func (tc *listTestContext) execute_list_with_tag(tag string) {
tc.t.Helper()
cmd := NewListCmd(func() *Client { return tc.client }, tc.buf)
cmd.Command.SetArgs([]string{"--tag", tag})
cmd.Command.SetErr(tc.buf)
tc.err = cmd.Command.Execute()
}

// --- Then ---

func (tc *listTestContext) command_has_no_error() {
Expand Down
12 changes: 12 additions & 0 deletions cli/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"strings"

"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -52,6 +53,17 @@ func NewShowCmd(clientFn func() *Client, out *bytes.Buffer) *ShowCmd {
if claimant != "" {
fmt.Fprintf(w, "Claimant: %s\n", claimant)
}
if tags, ok := result["tags"].([]any); ok && len(tags) > 0 {
rendered := make([]string, 0, len(tags))
for _, raw := range tags {
if tag, ok := raw.(string); ok && tag != "" {
rendered = append(rendered, tag)
}
}
if len(rendered) > 0 {
fmt.Fprintf(w, "Tags: %s\n", strings.Join(rendered, ", "))
}
}
if deps, ok := result["dependencies"].([]any); ok && len(deps) > 0 {
fmt.Fprintf(w, "Dependencies:\n")
for _, d := range deps {
Expand Down
16 changes: 16 additions & 0 deletions cli/show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ func TestShowCommand(t *testing.T) {
tc.output_not_contains("Branch:")
})

t.Run("displays tags in human output when present", func(t *testing.T) {
tc := newShowTestContext(t)

// Given
tc.server_that_returns_json(`{"id":"bf-abc","title":"My Rune","status":"open","priority":1,"tags":["backend","urgent"]}`)
tc.client_configured()

// When
tc.execute_show_with_human("bf-abc")

// Then
tc.command_has_no_error()
tc.output_contains("Tags:")
tc.output_contains("backend, urgent")
})

t.Run("returns error when server responds with not found", func(t *testing.T) {
tc := newShowTestContext(t)

Expand Down
25 changes: 25 additions & 0 deletions cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -45,6 +46,28 @@ func NewUpdateCmd(clientFn func() *Client, out *bytes.Buffer) *UpdateCmd {
branch, _ := cmd.Flags().GetString("branch")
body["branch"] = branch
}
if cmd.Flags().Changed("add-tag") {
tags, _ := cmd.Flags().GetStringSlice("add-tag")
normalized := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag != "" {
normalized = append(normalized, tag)
}
}
body["add_tags"] = normalized
}
if cmd.Flags().Changed("remove-tag") {
tags, _ := cmd.Flags().GetStringSlice("remove-tag")
normalized := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag != "" {
normalized = append(normalized, tag)
}
}
body["remove_tags"] = normalized
}

_, err := clientFn().DoPost("/update-rune", body)
if err != nil {
Expand All @@ -63,6 +86,8 @@ func NewUpdateCmd(clientFn func() *Client, out *bytes.Buffer) *UpdateCmd {
cmd.Flags().String("priority", "", "new priority (0-4)")
cmd.Flags().StringP("description", "d", "", "new description")
cmd.Flags().String("branch", "", "branch name")
cmd.Flags().StringSlice("add-tag", nil, "tag to add (repeatable)")
cmd.Flags().StringSlice("remove-tag", nil, "tag to remove (repeatable)")
cmd.Flags().Bool("human", false, "human-readable output")

c.Command = cmd
Expand Down
33 changes: 33 additions & 0 deletions cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ func TestUpdateCommand(t *testing.T) {
tc.request_body_does_not_have_field("branch")
})

t.Run("includes lowercase add/remove tags when flags are set", func(t *testing.T) {
tc := newUpdateTestContext(t)

// Given
tc.server_that_captures_request_and_returns_no_content()
tc.client_configured()

// When
tc.execute_update("bf-abc", "--add-tag", "Backend", "--remove-tag", " old ")

// Then
tc.command_has_no_error()
tc.request_body_has_array_field("add_tags", "backend")
tc.request_body_has_array_field("remove_tags", "old")
})

t.Run("returns error when server responds with error", func(t *testing.T) {
tc := newUpdateTestContext(t)

Expand Down Expand Up @@ -230,3 +246,20 @@ func (tc *updateTestContext) request_body_does_not_have_field(key string) {
_, exists := tc.receivedBody[key]
assert.False(tc.t, exists, "expected field %q to be absent from request body", key)
}

func (tc *updateTestContext) request_body_has_array_field(key string, expected ...string) {
tc.t.Helper()
require.NotNil(tc.t, tc.receivedBody)
raw, ok := tc.receivedBody[key].([]any)
require.True(tc.t, ok, "expected %q to be an array", key)
actual := make([]string, 0, len(raw))
for i, item := range raw {
s, ok := item.(string)
if !ok {
require.Fail(tc.t, "request_body_has_array_field: non-string element in array",
"expected all elements in %q to be strings, but element at index %d is %T: %v", key, i, item, item)
}
actual = append(actual, s)
}
assert.Equal(tc.t, expected, actual)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
4 changes: 4 additions & 0 deletions domain/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type CreateRune struct {
Priority int `json:"priority"`
ParentID string `json:"parent_id,omitempty"`
Branch *string `json:"branch,omitempty"`
Tags []string `json:"tags,omitempty"`
Type string `json:"type,omitempty"`
}

Expand All @@ -15,6 +16,9 @@ type UpdateRune struct {
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
Branch *string `json:"branch,omitempty"`
Tags *[]string `json:"tags,omitempty"`
AddTags []string `json:"add_tags,omitempty"`
RemoveTags []string `json:"remove_tags,omitempty"`
}

type ClaimRune struct {
Expand Down
12 changes: 12 additions & 0 deletions domain/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func TestCreateRuneCommand(t *testing.T) {
tc.cmd_json_omits_key("description")
tc.cmd_json_omits_key("parent_id")
tc.cmd_json_omits_key("branch")
tc.cmd_json_omits_key("tags")
})
}

Expand Down Expand Up @@ -70,6 +71,9 @@ func TestUpdateRuneCommand(t *testing.T) {
tc.cmd_json_omits_key("description")
tc.cmd_json_omits_key("priority")
tc.cmd_json_omits_key("branch")
tc.cmd_json_omits_key("tags")
tc.cmd_json_omits_key("add_tags")
tc.cmd_json_omits_key("remove_tags")
})
}

Expand Down Expand Up @@ -253,6 +257,7 @@ func (tc *cmdTestContext) create_rune_command() {
Priority: 1,
ParentID: "epic-1",
Branch: &branch,
Tags: []string{"backend", "p1"},
}
}

Expand All @@ -276,6 +281,9 @@ func (tc *cmdTestContext) update_rune_command_with_all_fields() {
Description: &desc,
Priority: &prio,
Branch: &branch,
Tags: &[]string{"backend", "urgent"},
AddTags: []string{"newtag"},
RemoveTags: []string{"oldtag"},
}
}

Expand Down Expand Up @@ -477,6 +485,10 @@ func (tc *cmdTestContext) update_rune_fields_match() {
assert.Equal(tc.t, *tc.updateRune.Priority, *tc.roundTrippedUpdateRune.Priority)
require.NotNil(tc.t, tc.roundTrippedUpdateRune.Branch)
assert.Equal(tc.t, *tc.updateRune.Branch, *tc.roundTrippedUpdateRune.Branch)
require.NotNil(tc.t, tc.roundTrippedUpdateRune.Tags)
assert.Equal(tc.t, *tc.updateRune.Tags, *tc.roundTrippedUpdateRune.Tags)
assert.Equal(tc.t, tc.updateRune.AddTags, tc.roundTrippedUpdateRune.AddTags)
assert.Equal(tc.t, tc.updateRune.RemoveTags, tc.roundTrippedUpdateRune.RemoveTags)
}

func (tc *cmdTestContext) claim_rune_fields_match() {
Expand Down
Loading
Loading