diff --git a/cli/create.go b/cli/create.go index 36ed09a..3a04d52 100644 --- a/cli/create.go +++ b/cli/create.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" "github.com/spf13/cobra" ) @@ -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") @@ -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 { @@ -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 diff --git a/cli/create_test.go b/cli/create_test.go index ccd9df2..e7f603c 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -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) @@ -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() { @@ -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) +} \ No newline at end of file diff --git a/cli/list.go b/cli/list.go index 00cd238..b922594 100644 --- a/cli/list.go +++ b/cli/list.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" "text/tabwriter" "github.com/spf13/cobra" @@ -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{} @@ -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 { @@ -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 diff --git a/cli/list_test.go b/cli/list_test.go index f118989..18d4eae 100644 --- a/cli/list_test.go +++ b/cli/list_test.go @@ -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) @@ -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() { diff --git a/cli/show.go b/cli/show.go index 9eb6b9e..0622416 100644 --- a/cli/show.go +++ b/cli/show.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" "github.com/spf13/cobra" ) @@ -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 { diff --git a/cli/show_test.go b/cli/show_test.go index 0570957..ff47be3 100644 --- a/cli/show_test.go +++ b/cli/show_test.go @@ -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) diff --git a/cli/update.go b/cli/update.go index 744ed9a..88e8424 100644 --- a/cli/update.go +++ b/cli/update.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "strconv" + "strings" "github.com/spf13/cobra" ) @@ -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 { @@ -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 diff --git a/cli/update_test.go b/cli/update_test.go index 0ac50cf..4af55ed 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -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) @@ -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) +} \ No newline at end of file diff --git a/domain/commands.go b/domain/commands.go index b050d5c..f469f10 100644 --- a/domain/commands.go +++ b/domain/commands.go @@ -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"` } @@ -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 { diff --git a/domain/commands_test.go b/domain/commands_test.go index d7f2831..390ebce 100644 --- a/domain/commands_test.go +++ b/domain/commands_test.go @@ -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") }) } @@ -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") }) } @@ -253,6 +257,7 @@ func (tc *cmdTestContext) create_rune_command() { Priority: 1, ParentID: "epic-1", Branch: &branch, + Tags: []string{"backend", "p1"}, } } @@ -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"}, } } @@ -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() { diff --git a/domain/events.go b/domain/events.go index 56d07cf..08485fd 100644 --- a/domain/events.go +++ b/domain/events.go @@ -35,6 +35,7 @@ type RuneCreated 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"` } @@ -48,6 +49,9 @@ type RuneUpdated 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 RuneClaimed struct { diff --git a/domain/events_test.go b/domain/events_test.go index 5bec3a8..1bec4be 100644 --- a/domain/events_test.go +++ b/domain/events_test.go @@ -56,6 +56,7 @@ func TestRuneCreated(t *testing.T) { tc.json_omits_key("description") tc.json_omits_key("parent_id") tc.json_omits_key("branch") + tc.json_omits_key("tags") }) } @@ -87,6 +88,9 @@ func TestRuneUpdated(t *testing.T) { tc.json_omits_key("description") tc.json_omits_key("priority") tc.json_omits_key("branch") + tc.json_omits_key("tags") + tc.json_omits_key("add_tags") + tc.json_omits_key("remove_tags") }) } @@ -376,6 +380,7 @@ func (tc *testContext) rune_created_event() { Priority: 1, ParentID: "epic-1", Branch: "feature/fix-bridge", + Tags: []string{"backend", "p1"}, } } @@ -400,6 +405,9 @@ func (tc *testContext) rune_updated_event_with_all_fields() { Description: &desc, Priority: &prio, Branch: &branch, + Tags: &[]string{"backend", "urgent"}, + AddTags: []string{"newtag"}, + RemoveTags: []string{"oldtag"}, } } @@ -662,6 +670,7 @@ func (tc *testContext) rune_created_json_has_expected_keys() { assert.Contains(tc.t, tc.jsonMap, "id") assert.Contains(tc.t, tc.jsonMap, "title") assert.Contains(tc.t, tc.jsonMap, "priority") + assert.Contains(tc.t, tc.jsonMap, "tags") } func (tc *testContext) rune_updated_fields_match() { @@ -675,6 +684,14 @@ func (tc *testContext) rune_updated_fields_match() { assert.Equal(tc.t, *tc.runeUpdated.Priority, *tc.roundTrippedUpdated.Priority) require.NotNil(tc.t, tc.roundTrippedUpdated.Branch) assert.Equal(tc.t, *tc.runeUpdated.Branch, *tc.roundTrippedUpdated.Branch) + require.NotNil(tc.t, tc.roundTrippedUpdated.Tags) + assert.Equal(tc.t, *tc.runeUpdated.Tags, *tc.roundTrippedUpdated.Tags) + assert.Equal(tc.t, tc.runeUpdated.AddTags, tc.roundTrippedUpdated.AddTags) + assert.Equal(tc.t, tc.runeUpdated.RemoveTags, tc.roundTrippedUpdated.RemoveTags) + // Explicitly verify JSON keys to catch typos in struct tags + assert.Contains(tc.t, tc.jsonMap, "tags") + assert.Contains(tc.t, tc.jsonMap, "add_tags") + assert.Contains(tc.t, tc.jsonMap, "remove_tags") } func (tc *testContext) rune_claimed_fields_match() { @@ -774,4 +791,4 @@ func (tc *testContext) is_inverse_false_for_forward_types() { func (tc *testContext) is_inverse_false_for_unknown() { tc.t.Helper() assert.False(tc.t, IsInverseRelationship("unknown")) -} +} \ No newline at end of file diff --git a/domain/handlers.go b/domain/handlers.go index 6e452d1..10f20a6 100644 --- a/domain/handlers.go +++ b/domain/handlers.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "fmt" + "sort" + "strings" "github.com/devzeebo/bifrost/core" ) @@ -21,6 +23,7 @@ type RuneState struct { Claimant string ParentID string Branch string + Tags []string Priority int Type string Exists bool @@ -40,6 +43,7 @@ func RebuildRuneState(events []core.Event) RuneState { state.Priority = data.Priority state.ParentID = data.ParentID state.Branch = data.Branch + state.Tags = normalizeTags(data.Tags) state.Type = data.Type if state.Type == "" { state.Type = "rune" @@ -60,6 +64,7 @@ func RebuildRuneState(events []core.Event) RuneState { if data.Branch != nil { state.Branch = *data.Branch } + state.Tags = applyTagMutations(state.Tags, data.Tags, data.AddTags, data.RemoveTags) case EventRuneClaimed: var data RuneClaimed _ = json.Unmarshal(evt.Data, &data) @@ -164,6 +169,7 @@ func HandleCreateRune(ctx context.Context, realmID string, cmd CreateRune, store Priority: cmd.Priority, ParentID: cmd.ParentID, Branch: branch, + Tags: normalizeTags(cmd.Tags), Type: runeType, } @@ -193,7 +199,16 @@ func HandleUpdateRune(ctx context.Context, realmID string, cmd UpdateRune, store return fmt.Errorf("cannot update shattered rune %q", cmd.ID) } - updated := RuneUpdated(cmd) + updated := RuneUpdated{ + ID: cmd.ID, + Title: cmd.Title, + Description: cmd.Description, + Priority: cmd.Priority, + Branch: cmd.Branch, + Tags: normalizeTagPointer(cmd.Tags), + AddTags: normalizeTags(cmd.AddTags), + RemoveTags: normalizeTags(cmd.RemoveTags), + } streamID := runeStreamID(cmd.ID) _, err = store.Append(ctx, realmID, streamID, len(events), []core.EventData{ @@ -673,3 +688,63 @@ func isNotFoundError(err error) bool { var nfe *core.NotFoundError return errors.As(err, &nfe) } + +func normalizeTagPointer(tags *[]string) *[]string { + if tags == nil { + return nil + } + // Preserve explicit empty slice intent (clear all tags) + if len(*tags) == 0 { + emptySlice := []string{} + return &emptySlice + } + normalized := normalizeTags(*tags) + return &normalized +} + +func normalizeTags(tags []string) []string { + if len(tags) == 0 { + return nil + } + seen := make(map[string]struct{}, len(tags)) + out := make([]string, 0, len(tags)) + for _, tag := range tags { + normalized := strings.ToLower(strings.TrimSpace(tag)) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + sort.Strings(out) + return out +} + +func applyTagMutations(current []string, replacement *[]string, addTags []string, removeTags []string) []string { + next := normalizeTags(current) + if replacement != nil { + next = normalizeTags(*replacement) + } + if len(addTags) == 0 && len(removeTags) == 0 { + return next + } + set := make(map[string]struct{}, len(next)) + for _, tag := range next { + set[tag] = struct{}{} + } + for _, tag := range normalizeTags(addTags) { + set[tag] = struct{}{} + } + for _, tag := range normalizeTags(removeTags) { + delete(set, tag) + } + out := make([]string, 0, len(set)) + for tag := range set { + out = append(out, tag) + } + sort.Strings(out) + return out +} \ No newline at end of file diff --git a/domain/handlers_test.go b/domain/handlers_test.go index c404ae3..b8dd92f 100644 --- a/domain/handlers_test.go +++ b/domain/handlers_test.go @@ -164,6 +164,32 @@ func TestRebuildRuneState(t *testing.T) { // Then tc.state_has_parent_id("bf-a1b2") }) + + t.Run("normalizes tags to lowercase on create", func(t *testing.T) { + tc := newHandlerTestContext(t) + + // Given + tc.events_from_created_rune_with_tags("Feature", " backend ", "feature") + + // When + tc.state_is_rebuilt() + + // Then + tc.state_has_tags("backend", "feature") + }) + + t.Run("applies tag add and remove updates", func(t *testing.T) { + tc := newHandlerTestContext(t) + + // Given + tc.events_from_created_and_tag_updated_rune() + + // When + tc.state_is_rebuilt() + + // Then + tc.state_has_tags("api", "feature") + }) } func TestHandleCreateRune(t *testing.T) { @@ -1583,6 +1609,15 @@ func (tc *handlerTestContext) events_from_created_rune_with_branch(branch string } } +func (tc *handlerTestContext) events_from_created_rune_with_tags(tags ...string) { + tc.t.Helper() + tc.events = []core.Event{ + makeEvent(EventRuneCreated, RuneCreated{ + ID: "bf-a1b2", Title: "Fix the bridge", Description: "Needs repair", Priority: 1, Tags: tags, + }), + } +} + func (tc *handlerTestContext) events_from_created_rune_with_branch_then_updated(initialBranch, updatedBranch string) { tc.t.Helper() tc.events = []core.Event{ @@ -1609,6 +1644,20 @@ func (tc *handlerTestContext) events_from_created_and_updated_rune() { } } +func (tc *handlerTestContext) events_from_created_and_tag_updated_rune() { + tc.t.Helper() + tc.events = []core.Event{ + makeEvent(EventRuneCreated, RuneCreated{ + ID: "bf-a1b2", Title: "Fix the bridge", Priority: 1, Tags: []string{"feature", "backend"}, + }), + makeEvent(EventRuneUpdated, RuneUpdated{ + ID: "bf-a1b2", + AddTags: []string{"API"}, + RemoveTags: []string{"BACKEND"}, + }), + } +} + func (tc *handlerTestContext) events_from_created_and_claimed_rune() { tc.t.Helper() tc.events = []core.Event{ @@ -2086,6 +2135,11 @@ func (tc *handlerTestContext) state_has_branch(expected string) { assert.Equal(tc.t, expected, tc.state.Branch) } +func (tc *handlerTestContext) state_has_tags(expected ...string) { + tc.t.Helper() + assert.Equal(tc.t, expected, tc.state.Tags) +} + func (tc *handlerTestContext) created_event_was_returned() { tc.t.Helper() assert.NotEmpty(tc.t, tc.createdEvent.ID) diff --git a/domain/projectors/rune_detail.go b/domain/projectors/rune_detail.go index d0998b9..652c6b6 100644 --- a/domain/projectors/rune_detail.go +++ b/domain/projectors/rune_detail.go @@ -28,6 +28,7 @@ type RuneDetail struct { Claimant string `json:"claimant,omitempty"` ParentID string `json:"parent_id,omitempty"` Branch string `json:"branch,omitempty"` + Tags []string `json:"tags,omitempty"` Type string `json:"type,omitempty"` Dependencies []DependencyRef `json:"dependencies"` Notes []NoteEntry `json:"notes"` @@ -93,6 +94,7 @@ func (p *RuneDetailProjector) handleCreated(ctx context.Context, event core.Even Priority: data.Priority, ParentID: data.ParentID, Branch: data.Branch, + Tags: normalizeTags(data.Tags), Type: data.Type, Dependencies: []DependencyRef{}, Notes: []NoteEntry{}, @@ -138,6 +140,7 @@ func (p *RuneDetailProjector) handleUpdated(ctx context.Context, event core.Even if data.Branch != nil { detail.Branch = *data.Branch } + detail.Tags = applyTagMutations(detail.Tags, data.Tags, data.AddTags, data.RemoveTags) detail.UpdatedAt = event.Timestamp return store.Put(ctx, event.RealmID, "rune_detail", data.ID, detail) } diff --git a/domain/projectors/rune_detail_test.go b/domain/projectors/rune_detail_test.go index f1bb0b6..6e6b049 100644 --- a/domain/projectors/rune_detail_test.go +++ b/domain/projectors/rune_detail_test.go @@ -46,6 +46,7 @@ func TestRuneDetailProjector(t *testing.T) { tc.stored_detail_has_description("Needs repair") tc.stored_detail_has_status("draft") tc.stored_detail_has_priority(1) + tc.stored_detail_has_tags("backend", "feature") tc.stored_detail_has_empty_dependencies() tc.stored_detail_has_empty_notes() }) @@ -104,6 +105,23 @@ func TestRuneDetailProjector(t *testing.T) { tc.stored_detail_has_priority(5) }) + t.Run("handles RuneUpdated tag mutations", func(t *testing.T) { + tc := newRuneDetailTestContext(t) + + // Given + tc.a_rune_detail_projector() + tc.a_store() + tc.existing_detail_with_tags("bf-a1b2", []string{"backend", "feature"}) + tc.a_rune_updated_event_with_tags("bf-a1b2", []string{"api"}, []string{"BACKEND"}) + + // When + tc.handle_is_called() + + // Then + tc.no_error() + tc.stored_detail_has_tags("api", "feature") + }) + t.Run("handles RuneClaimed", func(t *testing.T) { tc := newRuneDetailTestContext(t) @@ -421,7 +439,7 @@ func (tc *runeDetailTestContext) a_store() { func (tc *runeDetailTestContext) a_rune_created_event(id, title, description string, priority int, parentID string) { tc.t.Helper() tc.event = makeEventWithTimestamp(domain.EventRuneCreated, domain.RuneCreated{ - ID: id, Title: title, Description: description, Priority: priority, ParentID: parentID, + ID: id, Title: title, Description: description, Priority: priority, ParentID: parentID, Tags: []string{"Feature", " backend "}, }, time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)) } @@ -453,6 +471,13 @@ func (tc *runeDetailTestContext) a_rune_updated_event_with_branch(id string, tit }) } +func (tc *runeDetailTestContext) a_rune_updated_event_with_tags(id string, addTags, removeTags []string) { + tc.t.Helper() + tc.event = makeEvent(domain.EventRuneUpdated, domain.RuneUpdated{ + ID: id, AddTags: addTags, RemoveTags: removeTags, + }) +} + func (tc *runeDetailTestContext) a_rune_claimed_event(id, claimant string) { tc.t.Helper() tc.event = makeEvent(domain.EventRuneClaimed, domain.RuneClaimed{ @@ -577,6 +602,21 @@ func (tc *runeDetailTestContext) existing_detail_with_note(id, noteText string) tc.store.put(tc.realmID, "rune_detail", id, detail) } +func (tc *runeDetailTestContext) existing_detail_with_tags(id string, tags []string) { + tc.t.Helper() + tc.a_store() + detail := RuneDetail{ + ID: id, + Title: "Existing rune", + Status: "open", + Priority: 1, + Tags: tags, + Dependencies: []DependencyRef{}, + Notes: []NoteEntry{}, + } + tc.store.put(tc.realmID, "rune_detail", id, detail) +} + // --- When --- func (tc *runeDetailTestContext) name_is_called() { @@ -703,6 +743,12 @@ func (tc *runeDetailTestContext) stored_detail_has_type(expected string) { assert.Equal(tc.t, expected, tc.storedDetail.Type) } +func (tc *runeDetailTestContext) stored_detail_has_tags(expected ...string) { + tc.t.Helper() + require.NotNil(tc.t, tc.storedDetail) + assert.Equal(tc.t, expected, tc.storedDetail.Tags) +} + func (tc *runeDetailTestContext) detail_was_deleted(id string) { tc.t.Helper() var detail RuneDetail diff --git a/domain/projectors/rune_summary_projector.go b/domain/projectors/rune_summary_projector.go index 6818852..6c6e811 100644 --- a/domain/projectors/rune_summary_projector.go +++ b/domain/projectors/rune_summary_projector.go @@ -18,6 +18,7 @@ type RuneSummary struct { Claimant string `json:"claimant,omitempty"` ParentID string `json:"parent_id,omitempty"` Branch string `json:"branch,omitempty"` + Tags []string `json:"tags,omitempty"` Type string `json:"type,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -76,6 +77,7 @@ func (p *RuneSummaryProjector) handleCreated(ctx context.Context, event core.Eve Priority: data.Priority, ParentID: data.ParentID, Branch: data.Branch, + Tags: normalizeTags(data.Tags), Type: data.Type, CreatedAt: event.Timestamp, UpdatedAt: event.Timestamp, @@ -115,6 +117,7 @@ func (p *RuneSummaryProjector) handleUpdated(ctx context.Context, event core.Eve if data.Branch != nil { summary.Branch = *data.Branch } + summary.Tags = applyTagMutations(summary.Tags, data.Tags, data.AddTags, data.RemoveTags) summary.UpdatedAt = event.Timestamp return store.Put(ctx, event.RealmID, "rune_summary", data.ID, summary) } diff --git a/domain/projectors/rune_summary_projector_test.go b/domain/projectors/rune_summary_projector_test.go index 25fa6c0..f85cb14 100644 --- a/domain/projectors/rune_summary_projector_test.go +++ b/domain/projectors/rune_summary_projector_test.go @@ -59,6 +59,7 @@ func TestRuneSummaryProjector(t *testing.T) { tc.stored_summary_has_status("draft") tc.stored_summary_has_priority(1) tc.stored_summary_has_parent_id("") + tc.stored_summary_has_tags("backend", "feature") }) t.Run("handles RuneCreated with parent ID", func(t *testing.T) { @@ -149,6 +150,23 @@ func TestRuneSummaryProjector(t *testing.T) { tc.stored_summary_has_priority(5) }) + t.Run("handles RuneUpdated tag mutations", func(t *testing.T) { + tc := newRuneSummaryTestContext(t) + + // Given + tc.a_rune_summary_projector() + tc.a_store() + tc.existing_summary_with_tags("bf-a1b2", "Old title", "open", 1, []string{"backend", "feature"}) + tc.a_rune_updated_event_with_tags("bf-a1b2", []string{"api"}, []string{"BACKEND"}) + + // When + tc.handle_is_called() + + // Then + tc.no_error() + tc.stored_summary_has_tags("api", "feature") + }) + t.Run("handles RuneUpdated with branch", func(t *testing.T) { tc := newRuneSummaryTestContext(t) @@ -396,7 +414,7 @@ func (tc *runeSummaryTestContext) a_store() { func (tc *runeSummaryTestContext) a_rune_created_event(id, title string, priority int, parentID string) { tc.t.Helper() tc.event = makeEvent(domain.EventRuneCreated, domain.RuneCreated{ - ID: id, Title: title, Priority: priority, ParentID: parentID, + ID: id, Title: title, Priority: priority, ParentID: parentID, Tags: []string{"Feature", " backend "}, }) } @@ -435,6 +453,13 @@ func (tc *runeSummaryTestContext) a_rune_updated_event_with_branch(id string, ti }) } +func (tc *runeSummaryTestContext) a_rune_updated_event_with_tags(id string, addTags, removeTags []string) { + tc.t.Helper() + tc.event = makeEvent(domain.EventRuneUpdated, domain.RuneUpdated{ + ID: id, AddTags: addTags, RemoveTags: removeTags, + }) +} + func (tc *runeSummaryTestContext) a_rune_updated_event_with_timestamp(id string, title *string, priority *int) { tc.t.Helper() tc.event = makeEventWithTimestamp(domain.EventRuneUpdated, domain.RuneUpdated{ @@ -537,6 +562,19 @@ func (tc *runeSummaryTestContext) existing_summary_with_timestamps(id, title, st tc.store.put(tc.realmID, "rune_summary", id, summary) } +func (tc *runeSummaryTestContext) existing_summary_with_tags(id, title, status string, priority int, tags []string) { + tc.t.Helper() + tc.a_store() + summary := RuneSummary{ + ID: id, + Title: title, + Status: status, + Priority: priority, + Tags: tags, + } + tc.store.put(tc.realmID, "rune_summary", id, summary) +} + // --- When --- func (tc *runeSummaryTestContext) name_is_called() { @@ -620,6 +658,12 @@ func (tc *runeSummaryTestContext) stored_summary_has_type(expected string) { assert.Equal(tc.t, expected, tc.storedSummary.Type) } +func (tc *runeSummaryTestContext) stored_summary_has_tags(expected ...string) { + tc.t.Helper() + require.NotNil(tc.t, tc.storedSummary) + assert.Equal(tc.t, expected, tc.storedSummary.Tags) +} + func (tc *runeSummaryTestContext) stored_summary_has_created_at() { tc.t.Helper() require.NotNil(tc.t, tc.storedSummary) diff --git a/domain/projectors/tag_utils.go b/domain/projectors/tag_utils.go new file mode 100644 index 0000000..9004f51 --- /dev/null +++ b/domain/projectors/tag_utils.go @@ -0,0 +1,53 @@ +package projectors + +import ( + "sort" + "strings" +) + +func normalizeTags(tags []string) []string { + if len(tags) == 0 { + return nil + } + seen := make(map[string]struct{}, len(tags)) + out := make([]string, 0, len(tags)) + for _, tag := range tags { + normalized := strings.ToLower(strings.TrimSpace(tag)) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + sort.Strings(out) + return out +} + +func applyTagMutations(current []string, replacement *[]string, addTags []string, removeTags []string) []string { + next := normalizeTags(current) + if replacement != nil { + next = normalizeTags(*replacement) + } + if len(addTags) == 0 && len(removeTags) == 0 { + return next + } + set := make(map[string]struct{}, len(next)) + for _, tag := range next { + set[tag] = struct{}{} + } + for _, tag := range normalizeTags(addTags) { + set[tag] = struct{}{} + } + for _, tag := range normalizeTags(removeTags) { + delete(set, tag) + } + out := make([]string, 0, len(set)) + for tag := range set { + out = append(out, tag) + } + sort.Strings(out) + return out +} diff --git a/server/handlers.go b/server/handlers.go index 7ce4a5f..e1b0288 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -650,8 +650,9 @@ func (h *Handlers) ListRunes(w http.ResponseWriter, r *http.Request) { assigneeFilter := r.URL.Query().Get("assignee") branchFilter := r.URL.Query().Get("branch") sagaFilter := r.URL.Query().Get("saga") + tagFilters := parseTagFilters(r) - if statusFilter != "" || priorityFilter != "" || assigneeFilter != "" || branchFilter != "" || sagaFilter != "" { + if statusFilter != "" || priorityFilter != "" || assigneeFilter != "" || branchFilter != "" || sagaFilter != "" || len(tagFilters) > 0 { var filtered []json.RawMessage for _, raw := range runes { var item map[string]any @@ -683,6 +684,9 @@ func (h *Handlers) ListRunes(w http.ResponseWriter, r *http.Request) { continue } } + if len(tagFilters) > 0 && !itemHasAnyTag(item, tagFilters) { + continue + } filtered = append(filtered, raw) } runes = filtered @@ -1037,3 +1041,59 @@ func isNotFound(err error) bool { func (h *Handlers) runSyncQuietly(r *http.Request) { h.engine.RunCatchUpOnce(r.Context()) } + +func parseTagFilters(r *http.Request) []string { + collected := make([]string, 0) + collected = append(collected, r.URL.Query()["tag"]...) + if csv := r.URL.Query().Get("tags"); csv != "" { + collected = append(collected, strings.Split(csv, ",")...) + } + return normalizeTagList(collected) +} + +func normalizeTagList(tags []string) []string { + if len(tags) == 0 { + return nil + } + seen := make(map[string]struct{}, len(tags)) + out := make([]string, 0, len(tags)) + for _, tag := range tags { + normalized := strings.ToLower(strings.TrimSpace(tag)) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + return out +} + +func itemHasAnyTag(item map[string]any, wanted []string) bool { + rawTags, exists := item["tags"] + if !exists { + return false + } + list, ok := rawTags.([]any) + if !ok { + return false + } + itemTags := make(map[string]struct{}, len(list)) + for _, raw := range list { + tag, ok := raw.(string) + if !ok { + continue + } + for _, normalized := range normalizeTagList([]string{tag}) { + itemTags[normalized] = struct{}{} + } + } + for _, wantedTag := range wanted { + if _, ok := itemTags[wantedTag]; ok { + return true + } + } + return false +} diff --git a/server/handlers_test.go b/server/handlers_test.go index bd16d65..1864460 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -704,6 +704,23 @@ func TestListRunesHandler(t *testing.T) { tc.response_array_has_length(3) }) + t.Run("filters runes by tag query parameter", func(t *testing.T) { + tc := newHandlerTestContext(t) + + // Given + tc.handlers_configured() + tc.request_has_realm_id("realm-1") + tc.has_tagged_runes("realm-1") + + // When + tc.get("/runes?tag=backend") + + // Then + tc.status_is(http.StatusOK) + tc.response_array_has_length(1) + tc.response_array_contains_rune_id("bf-0001") + }) + t.Run("excludes runes with open blockers when blocked=false", func(t *testing.T) { tc := newHandlerTestContext(t) @@ -1594,6 +1611,16 @@ func (tc *handlerTestContext) has_runes_with_branches(realmID string) { }) } +func (tc *handlerTestContext) has_tagged_runes(realmID string) { + tc.t.Helper() + _ = tc.projectionStore.Put(context.Background(), realmID, "rune_summary", "bf-0001", map[string]any{ + "id": "bf-0001", "title": "Backend Rune", "status": "open", "priority": float64(0), "tags": []string{"backend"}, + }) + _ = tc.projectionStore.Put(context.Background(), realmID, "rune_summary", "bf-0002", map[string]any{ + "id": "bf-0002", "title": "UI Rune", "status": "open", "priority": float64(1), "tags": []string{"ui"}, + }) +} + func (tc *handlerTestContext) has_rune_detail(realmID, runeID string) { tc.t.Helper() _ = tc.projectionStore.Put(context.Background(), realmID, "rune_detail", runeID, map[string]any{ diff --git a/ui/src/lib/api.spec.ts b/ui/src/lib/api.spec.ts index a06d68f..b377d65 100644 --- a/ui/src/lib/api.spec.ts +++ b/ui/src/lib/api.spec.ts @@ -177,8 +177,8 @@ describe("ApiClient", () => { describe("getRunes", () => { test("sends GET request to /api/runes with realm header", async () => { const runes = [ - { id: "1", title: "Rune 1", status: "open" as const, priority: 1, realm_id: "test-realm", created_at: "", updated_at: "" }, - { id: "2", title: "Rune 2", status: "open" as const, priority: 1, realm_id: "test-realm", created_at: "", updated_at: "" }, + { id: "1", title: "Rune 1", status: "open" as const, priority: 1, realm_id: "test-realm", created_at: "", updated_at: "", tags: [] }, + { id: "2", title: "Rune 2", status: "open" as const, priority: 1, realm_id: "test-realm", created_at: "", updated_at: "", tags: [] }, ]; mockFetch.mockResolvedValueOnce({ @@ -203,6 +203,31 @@ describe("ApiClient", () => { }); + describe("getRunes tag normalization", () => { + test("normalizes tags to lowercase", async () => { + const runes = [ + { + id: "1", + title: "Rune 1", + status: "open" as const, + priority: 1, + realm_id: "test-realm", + created_at: "", + updated_at: "", + tags: ["Backend", " urgent "], + }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => runes, + }); + + const result = await apiClient.getRunes("test-realm"); + expect(result[0].tags).toEqual(["backend", "urgent"]); + }); + }); + describe("getRune", () => { test("sends GET request to /api/rune with realm header", async () => { const rune = { @@ -327,6 +352,24 @@ describe("ApiClient", () => { }) ); }); + + test("includes lowercase tags in update command", async () => { + const updates = { tags: ["Backend", " urgent "] }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + }); + + await apiClient.updateRune("test-realm", "1", updates); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/update-rune", + expect.objectContaining({ + body: JSON.stringify({ id: "1", tags: ["backend", "urgent"] }), + }) + ); + }); }); describe("deleteRune", () => { diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index c28ed95..184699b 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -142,7 +142,24 @@ export class ApiClient { assignee_id: raw.assignee_id, saga_id: raw.saga_id, dependencies: normalizeDependencies(raw.dependencies), - tags: Array.isArray(raw.tags) ? raw.tags : [], + tags: Array.isArray(raw.tags) + ? raw.tags + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim().toLowerCase()) + .filter((tag) => tag.length > 0) + : [], + }; + } + + private normalizeRuneListItem(raw: RuneListItem): RuneListItem { + return { + ...raw, + tags: Array.isArray(raw.tags) + ? raw.tags + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim().toLowerCase()) + .filter((tag) => tag.length > 0) + : [], }; } @@ -183,18 +200,20 @@ export class ApiClient { // Runes async getRunes(realmId: string): Promise { try { - return await this.request("/runes", { + const items = await this.request("/runes", { method: "GET", headers: this.withRealmHeader(realmId), }); + return items.map((item) => this.normalizeRuneListItem(item)); } catch (error) { if (!(error instanceof ApiError) || error.status !== 404) { throw error; } - return this.request(`/realms/${realmId}/runes`, { + const items = await this.request(`/realms/${realmId}/runes`, { method: "GET", }); + return items.map((item) => this.normalizeRuneListItem(item)); } } @@ -303,6 +322,7 @@ export class ApiClient { description?: string; priority?: number; branch?: string; + tags?: string[]; } = { id: runeId, }; @@ -319,6 +339,12 @@ export class ApiClient { if (typeof updates.branch === "string") { command.branch = updates.branch; } + if (Array.isArray(updates.tags)) { + command.tags = updates.tags + .filter((tag): tag is string => typeof tag === "string") + .map((tag) => tag.trim().toLowerCase()) + .filter((tag) => tag.length > 0); + } await this.request("/update-rune", { method: "POST", diff --git a/ui/src/types/rune.ts b/ui/src/types/rune.ts index 6066c45..6bb1966 100644 --- a/ui/src/types/rune.ts +++ b/ui/src/types/rune.ts @@ -25,6 +25,7 @@ export interface RuneListItem { claimant_username?: string; dependencies_count?: number; dependents_count?: number; + tags?: string[]; realm_id: string; created_at: string; updated_at: string;