From 3a0e77406757140d26dc247f09eaae9b9306d2e6 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Sat, 7 Mar 2026 23:14:09 +0100 Subject: [PATCH] feat: add custom properties support for beans - Add Properties map[string]any field to Bean with SetProperty/UnsetProperty/GetProperty helpers - Add scalar JSON to GraphQL schema with properties on Bean, CreateBeanInput, UpdateBeanInput - Support replace-all (properties) and granular (setProperties/unsetProperties) update semantics - Add --set key=value flag to create and update commands, --unset flag to update - Display properties in beans show output (styled, raw, JSON) - YAML-based auto type detection for CLI values (int, float, bool, string) - Full test coverage across bean model, GraphQL resolvers, and CLI flag parser Refs: beans-digu --- ...igu--support-custom-properties-on-beans.md | 17 +- cmd/create.go | 11 + cmd/properties.go | 37 ++ cmd/properties_test.go | 92 +++++ cmd/show.go | 26 ++ cmd/update.go | 23 +- gqlgen.yml | 3 + internal/bean/bean.go | 115 ++++--- internal/bean/bean_test.go | 323 ++++++++++++++++++ internal/graph/generated.go | 114 ++++++- internal/graph/model/models_gen.go | 8 + internal/graph/schema.graphqls | 12 + internal/graph/schema.resolvers.go | 28 ++ internal/graph/schema.resolvers_test.go | 173 ++++++++++ 14 files changed, 935 insertions(+), 47 deletions(-) create mode 100644 cmd/properties.go create mode 100644 cmd/properties_test.go diff --git a/.beans/beans-digu--support-custom-properties-on-beans.md b/.beans/beans-digu--support-custom-properties-on-beans.md index 1793ea7f..13e06dd9 100644 --- a/.beans/beans-digu--support-custom-properties-on-beans.md +++ b/.beans/beans-digu--support-custom-properties-on-beans.md @@ -1,10 +1,11 @@ --- +# beans-digu title: Support custom properties on beans -status: todo +status: completed type: feature priority: normal created_at: 2025-12-13T00:52:24Z -updated_at: 2025-12-13T02:02:08Z +updated_at: 2026-02-14T20:30:39Z --- Allow users to attach custom key-value properties to beans. Custom properties should live under a dedicated `properties` key in the frontmatter to keep them separate from built-in fields. @@ -30,3 +31,15 @@ properties: - Should be exposed via GraphQL (probably as JSON scalar or key-value pairs) - Could support filtering/searching by property values in the future - CLI: `beans update --set key=value` or similar + +## Summary of Changes + +- Added `Properties map[string]any` field to `Bean`, `frontMatter`, and `renderFrontMatter` structs +- Added helper methods: `SetProperty`, `UnsetProperty`, `GetProperty` (with nil-map safety and empty→nil normalization) +- Added `scalar JSON` to GraphQL schema mapped to gqlgen's `graphql.Map` +- Added `properties` field to `Bean` type, `CreateBeanInput`, and `UpdateBeanInput` (with `setProperties`/`unsetProperties` for granular updates) +- Resolver enforces mutual exclusivity between `properties` and `setProperties`/`unsetProperties` +- CLI: `--set key=value` (repeatable) on both `create` and `update`, `--unset key` on `update` +- Value types auto-detected via YAML unmarshaling (3→int, true→bool, 4.5→float, text→string) +- Properties displayed in `beans show` output between relationships and body +- Full test coverage across all layers (bean model, GraphQL resolvers, CLI flag parser) diff --git a/cmd/create.go b/cmd/create.go index 6748d7b2..e1a31d55 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -24,6 +24,7 @@ var ( createBlocking []string createBlockedBy []string createPrefix string + createSet []string createJSON bool ) @@ -98,6 +99,15 @@ var createCmd = &cobra.Command{ input.Prefix = &createPrefix } + // Add custom properties + if len(createSet) > 0 { + props, err := parsePropertyFlags(createSet) + if err != nil { + return cmdError(createJSON, output.ErrValidation, "%s", err) + } + input.Properties = props + } + // Create via GraphQL mutation resolver := &graph.Resolver{Core: core} b, err := resolver.Mutation().CreateBean(context.Background(), input) @@ -139,6 +149,7 @@ func init() { createCmd.Flags().StringArrayVar(&createBlocking, "blocking", nil, "ID of bean this blocks (can be repeated)") createCmd.Flags().StringArrayVar(&createBlockedBy, "blocked-by", nil, "ID of bean that blocks this one (can be repeated)") createCmd.Flags().StringVar(&createPrefix, "prefix", "", "Custom ID prefix (overrides config prefix)") + createCmd.Flags().StringArrayVar(&createSet, "set", nil, "Set custom property (key=value, can be repeated)") createCmd.Flags().BoolVar(&createJSON, "json", false, "Output as JSON") createCmd.MarkFlagsMutuallyExclusive("body", "body-file") rootCmd.AddCommand(createCmd) diff --git a/cmd/properties.go b/cmd/properties.go new file mode 100644 index 00000000..bc7c3d38 --- /dev/null +++ b/cmd/properties.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// parsePropertyFlags parses --set flags in the form "key=value" and returns +// a map with YAML-inferred types (e.g., "3" → int, "true" → bool). +func parsePropertyFlags(flags []string) (map[string]interface{}, error) { + result := make(map[string]interface{}, len(flags)) + for _, flag := range flags { + key, value, ok := strings.Cut(flag, "=") + if !ok { + return nil, fmt.Errorf("invalid property format %q: expected key=value", flag) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("invalid property format %q: key cannot be empty", flag) + } + + // Use YAML unmarshaling for automatic type detection + var parsed any + if err := yaml.Unmarshal([]byte(value), &parsed); err != nil { + // Fall back to raw string if YAML parsing fails + parsed = value + } + // yaml.Unmarshal returns nil for empty string — preserve as empty string + if parsed == nil { + parsed = "" + } + result[key] = parsed + } + return result, nil +} diff --git a/cmd/properties_test.go b/cmd/properties_test.go new file mode 100644 index 00000000..77c849d3 --- /dev/null +++ b/cmd/properties_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "testing" +) + +func TestParsePropertyFlags(t *testing.T) { + tests := []struct { + name string + flags []string + wantErr bool + check func(map[string]interface{}) bool + }{ + { + name: "string value", + flags: []string{"author=alice"}, + check: func(m map[string]interface{}) bool { + return m["author"] == "alice" + }, + }, + { + name: "integer value", + flags: []string{"estimate=3"}, + check: func(m map[string]interface{}) bool { + return m["estimate"] == 3 + }, + }, + { + name: "boolean value", + flags: []string{"reviewed=true"}, + check: func(m map[string]interface{}) bool { + return m["reviewed"] == true + }, + }, + { + name: "float value", + flags: []string{"score=4.5"}, + check: func(m map[string]interface{}) bool { + return m["score"] == 4.5 + }, + }, + { + name: "empty value", + flags: []string{"note="}, + check: func(m map[string]interface{}) bool { + return m["note"] == "" + }, + }, + { + name: "value with equals sign", + flags: []string{"formula=a=b"}, + check: func(m map[string]interface{}) bool { + return m["formula"] == "a=b" + }, + }, + { + name: "multiple flags", + flags: []string{"author=alice", "estimate=3", "reviewed=true"}, + check: func(m map[string]interface{}) bool { + return m["author"] == "alice" && m["estimate"] == 3 && m["reviewed"] == true + }, + }, + { + name: "missing equals sign", + flags: []string{"badformat"}, + wantErr: true, + }, + { + name: "empty key", + flags: []string{"=value"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePropertyFlags(tt.flags) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil && !tt.check(got) { + t.Errorf("check failed, got: %v", got) + } + }) + } +} diff --git a/cmd/show.go b/cmd/show.go index 7f6d635b..44474477 100644 --- a/cmd/show.go +++ b/cmd/show.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "sort" "strings" "github.com/charmbracelet/glamour" @@ -156,6 +157,14 @@ func showStyledBean(b *bean.Bean) { header.WriteString(formatRelationships(b)) } + // Display properties + if len(b.Properties) > 0 { + header.WriteString("\n") + header.WriteString(ui.Muted.Render(strings.Repeat("─", 50))) + header.WriteString("\n") + header.WriteString(formatProperties(b.Properties)) + } + header.WriteString("\n") header.WriteString(ui.Muted.Render(strings.Repeat("─", 50))) @@ -206,6 +215,23 @@ func formatRelationships(b *bean.Bean) string { return strings.Join(parts, "\n") } +// formatProperties formats custom properties for display with sorted keys. +func formatProperties(props map[string]any) string { + keys := make([]string, 0, len(props)) + for k := range props { + keys = append(keys, k) + } + sort.Strings(keys) + + var parts []string + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s %v", + ui.Muted.Render(k+":"), + props[k])) + } + return strings.Join(parts, "\n") +} + func init() { showCmd.Flags().BoolVar(&showJSON, "json", false, "Output as JSON") showCmd.Flags().BoolVar(&showRaw, "raw", false, "Output raw markdown without styling") diff --git a/cmd/update.go b/cmd/update.go index 0caafcc3..d645d2c8 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -33,6 +33,8 @@ var ( updateRemoveBlockedBy []string updateTag []string updateRemoveTag []string + updateSet []string + updateUnset []string updateIfMatch string updateJSON bool ) @@ -101,7 +103,7 @@ var updateCmd = &cobra.Command{ // Require at least one change if len(changes) == 0 { return cmdError(updateJSON, output.ErrValidation, - "no changes specified (use --status, --type, --priority, --title, --body, --parent, --blocking, --blocked-by, --tag, or their --remove-* variants)") + "no changes specified (use --status, --type, --priority, --title, --body, --parent, --blocking, --blocked-by, --tag, --set, --unset, or their --remove-* variants)") } // Output result @@ -231,6 +233,20 @@ func buildUpdateInput(cmd *cobra.Command, existingTags []string, currentBody str changes = append(changes, "blocked-by") } + // Handle custom properties + if len(updateSet) > 0 { + props, err := parsePropertyFlags(updateSet) + if err != nil { + return input, nil, err + } + input.SetProperties = props + changes = append(changes, "properties") + } + if len(updateUnset) > 0 { + input.UnsetProperties = updateUnset + changes = append(changes, "properties") + } + return input, changes, nil } @@ -240,7 +256,8 @@ func hasFieldUpdates(input model.UpdateBeanInput) bool { input.Title != nil || input.Body != nil || input.BodyMod != nil || input.Tags != nil || input.AddTags != nil || input.RemoveTags != nil || input.Parent != nil || input.AddBlocking != nil || input.RemoveBlocking != nil || - input.AddBlockedBy != nil || input.RemoveBlockedBy != nil + input.AddBlockedBy != nil || input.RemoveBlockedBy != nil || + input.Properties != nil || input.SetProperties != nil || input.UnsetProperties != nil } // isConflictError returns true if the error is an ETag-related conflict error. @@ -290,6 +307,8 @@ func init() { updateCmd.Flags().StringArrayVar(&updateRemoveBlockedBy, "remove-blocked-by", nil, "ID of blocker bean to remove (can be repeated)") updateCmd.Flags().StringArrayVar(&updateTag, "tag", nil, "Add tag (can be repeated)") updateCmd.Flags().StringArrayVar(&updateRemoveTag, "remove-tag", nil, "Remove tag (can be repeated)") + updateCmd.Flags().StringArrayVar(&updateSet, "set", nil, "Set custom property (key=value, can be repeated)") + updateCmd.Flags().StringArrayVar(&updateUnset, "unset", nil, "Remove custom property (can be repeated)") updateCmd.Flags().StringVar(&updateIfMatch, "if-match", "", "Only update if etag matches (optimistic locking)") updateCmd.MarkFlagsMutuallyExclusive("parent", "remove-parent") updateCmd.Flags().BoolVar(&updateJSON, "json", false, "Output as JSON") diff --git a/gqlgen.yml b/gqlgen.yml index d8630c85..ae35b324 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -32,6 +32,9 @@ models: - github.com/99designs/gqlgen/graphql.Int - github.com/99designs/gqlgen/graphql.Int64 - github.com/99designs/gqlgen/graphql.Int32 + JSON: + model: + - github.com/99designs/gqlgen/graphql.Map Int: model: - github.com/99designs/gqlgen/graphql.Int diff --git a/internal/bean/bean.go b/internal/bean/bean.go index 1be2e964..6ec31398 100644 --- a/internal/bean/bean.go +++ b/internal/bean/bean.go @@ -161,20 +161,24 @@ type Bean struct { // BlockedBy is a list of bean IDs that are blocking this bean. BlockedBy []string `yaml:"blocked_by,omitempty" json:"blocked_by,omitempty"` + + // Properties is a map of custom key-value metadata. + Properties map[string]any `yaml:"properties,omitempty" json:"properties,omitempty"` } // frontMatter is the subset of Bean that gets serialized to YAML front matter. type frontMatter struct { - Title string `yaml:"title"` - Status string `yaml:"status"` - Type string `yaml:"type,omitempty"` - Priority string `yaml:"priority,omitempty"` - Tags []string `yaml:"tags,omitempty"` - CreatedAt *time.Time `yaml:"created_at,omitempty"` - UpdatedAt *time.Time `yaml:"updated_at,omitempty"` - Parent string `yaml:"parent,omitempty"` - Blocking []string `yaml:"blocking,omitempty"` - BlockedBy []string `yaml:"blocked_by,omitempty"` + Title string `yaml:"title"` + Status string `yaml:"status"` + Type string `yaml:"type,omitempty"` + Priority string `yaml:"priority,omitempty"` + Tags []string `yaml:"tags,omitempty"` + CreatedAt *time.Time `yaml:"created_at,omitempty"` + UpdatedAt *time.Time `yaml:"updated_at,omitempty"` + Parent string `yaml:"parent,omitempty"` + Blocking []string `yaml:"blocking,omitempty"` + BlockedBy []string `yaml:"blocked_by,omitempty"` + Properties map[string]any `yaml:"properties,omitempty"` } // Parse reads a bean from a reader (markdown with YAML front matter). @@ -189,47 +193,50 @@ func Parse(r io.Reader) (*Bean, error) { bodyStr := strings.TrimSuffix(string(body), "\n") return &Bean{ - Title: fm.Title, - Status: fm.Status, - Type: fm.Type, - Priority: fm.Priority, - Tags: fm.Tags, - CreatedAt: fm.CreatedAt, - UpdatedAt: fm.UpdatedAt, - Body: bodyStr, - Parent: fm.Parent, - Blocking: fm.Blocking, - BlockedBy: fm.BlockedBy, + Title: fm.Title, + Status: fm.Status, + Type: fm.Type, + Priority: fm.Priority, + Tags: fm.Tags, + CreatedAt: fm.CreatedAt, + UpdatedAt: fm.UpdatedAt, + Body: bodyStr, + Parent: fm.Parent, + Blocking: fm.Blocking, + BlockedBy: fm.BlockedBy, + Properties: fm.Properties, }, nil } // renderFrontMatter is used for YAML output with yaml.v3 (supports custom marshalers). type renderFrontMatter struct { - Title string `yaml:"title"` - Status string `yaml:"status"` - Type string `yaml:"type,omitempty"` - Priority string `yaml:"priority,omitempty"` - Tags []string `yaml:"tags,omitempty"` - CreatedAt *time.Time `yaml:"created_at,omitempty"` - UpdatedAt *time.Time `yaml:"updated_at,omitempty"` - Parent string `yaml:"parent,omitempty"` - Blocking []string `yaml:"blocking,omitempty"` - BlockedBy []string `yaml:"blocked_by,omitempty"` + Title string `yaml:"title"` + Status string `yaml:"status"` + Type string `yaml:"type,omitempty"` + Priority string `yaml:"priority,omitempty"` + Tags []string `yaml:"tags,omitempty"` + CreatedAt *time.Time `yaml:"created_at,omitempty"` + UpdatedAt *time.Time `yaml:"updated_at,omitempty"` + Parent string `yaml:"parent,omitempty"` + Blocking []string `yaml:"blocking,omitempty"` + BlockedBy []string `yaml:"blocked_by,omitempty"` + Properties map[string]any `yaml:"properties,omitempty"` } // Render serializes the bean back to markdown with YAML front matter. func (b *Bean) Render() ([]byte, error) { fm := renderFrontMatter{ - Title: b.Title, - Status: b.Status, - Type: b.Type, - Priority: b.Priority, - Tags: b.Tags, - CreatedAt: b.CreatedAt, - UpdatedAt: b.UpdatedAt, - Parent: b.Parent, - Blocking: b.Blocking, - BlockedBy: b.BlockedBy, + Title: b.Title, + Status: b.Status, + Type: b.Type, + Priority: b.Priority, + Tags: b.Tags, + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + Parent: b.Parent, + Blocking: b.Blocking, + BlockedBy: b.BlockedBy, + Properties: b.Properties, } fmBytes, err := yaml.Marshal(&fm) @@ -290,3 +297,29 @@ func (b *Bean) MarshalJSON() ([]byte, error) { ETag: b.ETag(), }) } + +// SetProperty sets a custom property on the bean. +func (b *Bean) SetProperty(key string, value any) { + if b.Properties == nil { + b.Properties = make(map[string]any) + } + b.Properties[key] = value +} + +// UnsetProperty removes a custom property from the bean. +// Normalizes empty maps to nil so omitempty works correctly. +func (b *Bean) UnsetProperty(key string) { + delete(b.Properties, key) + if len(b.Properties) == 0 { + b.Properties = nil + } +} + +// GetProperty returns a custom property value and whether it exists. +func (b *Bean) GetProperty(key string) (any, bool) { + if b.Properties == nil { + return nil, false + } + v, ok := b.Properties[key] + return v, ok +} diff --git a/internal/bean/bean_test.go b/internal/bean/bean_test.go index 8ffc9a92..90137d4f 100644 --- a/internal/bean/bean_test.go +++ b/internal/bean/bean_test.go @@ -2,6 +2,7 @@ package bean import ( "encoding/json" + "fmt" "strings" "testing" "time" @@ -1676,6 +1677,328 @@ func TestMarshalJSONIncludesETag(t *testing.T) { } } +func TestParseWithProperties(t *testing.T) { + tests := []struct { + name string + input string + expectedProperties map[string]any + }{ + { + name: "with string properties", + input: `--- +title: Test +status: todo +properties: + author: alice + github_issue: "#42" +---`, + expectedProperties: map[string]any{"author": "alice", "github_issue": "#42"}, + }, + { + name: "with mixed type properties", + input: `--- +title: Test +status: todo +properties: + estimate: 3 + reviewed: true + score: 4.5 +---`, + expectedProperties: map[string]any{"estimate": 3, "reviewed": true, "score": 4.5}, + }, + { + name: "without properties", + input: `--- +title: Test +status: todo +---`, + expectedProperties: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bean, err := Parse(strings.NewReader(tt.input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.expectedProperties == nil { + if bean.Properties != nil { + t.Errorf("Properties = %v, want nil", bean.Properties) + } + return + } + + if len(bean.Properties) != len(tt.expectedProperties) { + t.Errorf("Properties count = %d, want %d", len(bean.Properties), len(tt.expectedProperties)) + return + } + + for key, expected := range tt.expectedProperties { + got, ok := bean.Properties[key] + if !ok { + t.Errorf("missing property %q", key) + continue + } + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", expected) { + t.Errorf("Properties[%q] = %v (%T), want %v (%T)", key, got, got, expected, expected) + } + } + }) + } +} + +func TestRenderWithProperties(t *testing.T) { + tests := []struct { + name string + bean *Bean + contains []string + }{ + { + name: "with properties", + bean: &Bean{ + Title: "Test Bean", + Status: "todo", + Properties: map[string]any{"author": "alice", "estimate": 3}, + }, + contains: []string{ + "properties:", + "author: alice", + "estimate: 3", + }, + }, + { + name: "without properties", + bean: &Bean{ + Title: "Test Bean", + Status: "todo", + }, + contains: []string{ + "title: Test Bean", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := tt.bean.Render() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := string(output) + for _, want := range tt.contains { + if !strings.Contains(result, want) { + t.Errorf("output missing %q\ngot:\n%s", want, result) + } + } + + if tt.bean.Properties == nil && strings.Contains(result, "properties:") { + t.Errorf("output should not contain 'properties:' when no properties\ngot:\n%s", result) + } + }) + } +} + +func TestPropertiesRoundtrip(t *testing.T) { + tests := []struct { + name string + properties map[string]any + }{ + { + name: "string values", + properties: map[string]any{"author": "alice", "team": "backend"}, + }, + { + name: "numeric values", + properties: map[string]any{"estimate": 3, "score": 4.5}, + }, + { + name: "boolean values", + properties: map[string]any{"reviewed": true, "approved": false}, + }, + { + name: "mixed types", + properties: map[string]any{"author": "alice", "estimate": 3, "reviewed": true}, + }, + { + name: "nil properties", + properties: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := &Bean{ + Title: "Test", + Status: "todo", + Properties: tt.properties, + } + + rendered, err := original.Render() + if err != nil { + t.Fatalf("Render error: %v", err) + } + + parsed, err := Parse(strings.NewReader(string(rendered))) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + if tt.properties == nil { + if parsed.Properties != nil { + t.Errorf("Properties should be nil, got %v", parsed.Properties) + } + return + } + + if len(parsed.Properties) != len(tt.properties) { + t.Errorf("Properties count: got %d, want %d", len(parsed.Properties), len(tt.properties)) + return + } + + for key, expected := range tt.properties { + got, ok := parsed.Properties[key] + if !ok { + t.Errorf("missing property %q after roundtrip", key) + continue + } + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", expected) { + t.Errorf("Properties[%q] roundtrip: got %v (%T), want %v (%T)", key, got, got, expected, expected) + } + } + }) + } +} + +func TestPropertyHelperMethods(t *testing.T) { + t.Run("SetProperty", func(t *testing.T) { + b := &Bean{Title: "Test", Status: "todo"} + b.SetProperty("author", "alice") + if v, ok := b.Properties["author"]; !ok || v != "alice" { + t.Errorf("SetProperty failed: got %v", b.Properties) + } + + // Set on existing map + b.SetProperty("estimate", 3) + if len(b.Properties) != 2 { + t.Errorf("expected 2 properties, got %d", len(b.Properties)) + } + }) + + t.Run("UnsetProperty", func(t *testing.T) { + b := &Bean{ + Title: "Test", + Status: "todo", + Properties: map[string]any{"author": "alice", "estimate": 3}, + } + + b.UnsetProperty("author") + if _, ok := b.Properties["estimate"]; !ok { + t.Error("UnsetProperty removed wrong key") + } + if _, ok := b.Properties["author"]; ok { + t.Error("UnsetProperty didn't remove key") + } + + // Removing last property should nil-ify the map + b.UnsetProperty("estimate") + if b.Properties != nil { + t.Errorf("Properties should be nil after removing all, got %v", b.Properties) + } + }) + + t.Run("UnsetProperty on nil map", func(t *testing.T) { + b := &Bean{Title: "Test", Status: "todo"} + b.UnsetProperty("nonexistent") // should not panic + if b.Properties != nil { + t.Errorf("Properties should remain nil, got %v", b.Properties) + } + }) + + t.Run("GetProperty", func(t *testing.T) { + b := &Bean{ + Title: "Test", + Status: "todo", + Properties: map[string]any{"author": "alice"}, + } + + v, ok := b.GetProperty("author") + if !ok || v != "alice" { + t.Errorf("GetProperty('author') = %v, %v; want 'alice', true", v, ok) + } + + _, ok = b.GetProperty("nonexistent") + if ok { + t.Error("GetProperty('nonexistent') should return false") + } + }) + + t.Run("GetProperty on nil map", func(t *testing.T) { + b := &Bean{Title: "Test", Status: "todo"} + _, ok := b.GetProperty("anything") + if ok { + t.Error("GetProperty on nil map should return false") + } + }) +} + +func TestETagChangesWithProperties(t *testing.T) { + b := &Bean{ + Title: "Test", + Status: "todo", + } + etag1 := b.ETag() + + b.SetProperty("estimate", 3) + etag2 := b.ETag() + + if etag1 == etag2 { + t.Error("ETag should change when properties are added") + } + + b.SetProperty("estimate", 5) + etag3 := b.ETag() + + if etag2 == etag3 { + t.Error("ETag should change when property value changes") + } +} + +func TestPropertiesJSONSerialization(t *testing.T) { + b := &Bean{ + ID: "test-123", + Title: "Test", + Status: "todo", + Properties: map[string]any{"author": "alice", "estimate": 3}, + } + + data, err := json.Marshal(b) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + jsonStr := string(data) + if !strings.Contains(jsonStr, `"properties"`) { + t.Errorf("JSON should contain 'properties' field, got: %s", jsonStr) + } + if !strings.Contains(jsonStr, `"author":"alice"`) { + t.Errorf("JSON should contain author property, got: %s", jsonStr) + } + + // Verify omitempty works + b2 := &Bean{ + ID: "test-456", + Title: "Test", + Status: "todo", + } + data2, _ := json.Marshal(b2) + if strings.Contains(string(data2), `"properties"`) { + t.Errorf("JSON should not contain 'properties' when nil, got: %s", string(data2)) + } +} + func TestETagChangesAfterModification(t *testing.T) { // Verify that ETag changes reflect actual content changes // (this is important for optimistic concurrency control) diff --git a/internal/graph/generated.go b/internal/graph/generated.go index 5b1ea971..9800a4f8 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -64,6 +64,7 @@ type ComplexityRoot struct { ParentID func(childComplexity int) int Path func(childComplexity int) int Priority func(childComplexity int) int + Properties func(childComplexity int) int Slug func(childComplexity int) int Status func(childComplexity int) int Tags func(childComplexity int) int @@ -225,6 +226,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Bean.Priority(childComplexity), true + case "Bean.properties": + if e.complexity.Bean.Properties == nil { + break + } + + return e.complexity.Bean.Properties(childComplexity), true case "Bean.slug": if e.complexity.Bean.Slug == nil { break @@ -1112,6 +1119,35 @@ func (ec *executionContext) fieldContext_Bean_etag(_ context.Context, field grap return fc, nil } +func (ec *executionContext) _Bean_properties(ctx context.Context, field graphql.CollectedField, obj *bean.Bean) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Bean_properties, + func(ctx context.Context) (any, error) { + return obj.Properties, nil + }, + nil, + ec.marshalOJSON2map, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Bean_properties(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Bean", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type JSON does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Bean_parentId(ctx context.Context, field graphql.CollectedField, obj *bean.Bean) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1248,6 +1284,8 @@ func (ec *executionContext) fieldContext_Bean_blockedBy(ctx context.Context, fie return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1329,6 +1367,8 @@ func (ec *executionContext) fieldContext_Bean_blocking(ctx context.Context, fiel return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1409,6 +1449,8 @@ func (ec *executionContext) fieldContext_Bean_parent(_ context.Context, field gr return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1479,6 +1521,8 @@ func (ec *executionContext) fieldContext_Bean_children(ctx context.Context, fiel return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1560,6 +1604,8 @@ func (ec *executionContext) fieldContext_Mutation_createBean(ctx context.Context return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1641,6 +1687,8 @@ func (ec *executionContext) fieldContext_Mutation_updateBean(ctx context.Context return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1763,6 +1811,8 @@ func (ec *executionContext) fieldContext_Mutation_setParent(ctx context.Context, return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1844,6 +1894,8 @@ func (ec *executionContext) fieldContext_Mutation_addBlocking(ctx context.Contex return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -1925,6 +1977,8 @@ func (ec *executionContext) fieldContext_Mutation_removeBlocking(ctx context.Con return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -2006,6 +2060,8 @@ func (ec *executionContext) fieldContext_Mutation_addBlockedBy(ctx context.Conte return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -2087,6 +2143,8 @@ func (ec *executionContext) fieldContext_Mutation_removeBlockedBy(ctx context.Co return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -2168,6 +2226,8 @@ func (ec *executionContext) fieldContext_Query_bean(ctx context.Context, field g return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -2249,6 +2309,8 @@ func (ec *executionContext) fieldContext_Query_beans(ctx context.Context, field return ec.fieldContext_Bean_body(ctx, field) case "etag": return ec.fieldContext_Bean_etag(ctx, field) + case "properties": + return ec.fieldContext_Bean_properties(ctx, field) case "parentId": return ec.fieldContext_Bean_parentId(ctx, field) case "blockingIds": @@ -4029,7 +4091,7 @@ func (ec *executionContext) unmarshalInputCreateBeanInput(ctx context.Context, o asMap[k] = v } - fieldsInOrder := [...]string{"title", "type", "status", "priority", "tags", "body", "parent", "blocking", "blockedBy", "prefix"} + fieldsInOrder := [...]string{"title", "type", "status", "priority", "tags", "body", "parent", "blocking", "blockedBy", "prefix", "properties"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -4106,6 +4168,13 @@ func (ec *executionContext) unmarshalInputCreateBeanInput(ctx context.Context, o return it, err } it.Prefix = data + case "properties": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("properties")) + data, err := ec.unmarshalOJSON2map(ctx, v) + if err != nil { + return it, err + } + it.Properties = data } } @@ -4153,7 +4222,7 @@ func (ec *executionContext) unmarshalInputUpdateBeanInput(ctx context.Context, o asMap[k] = v } - fieldsInOrder := [...]string{"title", "status", "type", "priority", "tags", "addTags", "removeTags", "body", "bodyMod", "parent", "addBlocking", "removeBlocking", "addBlockedBy", "removeBlockedBy", "ifMatch"} + fieldsInOrder := [...]string{"title", "status", "type", "priority", "tags", "addTags", "removeTags", "body", "bodyMod", "parent", "addBlocking", "removeBlocking", "addBlockedBy", "removeBlockedBy", "properties", "setProperties", "unsetProperties", "ifMatch"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -4258,6 +4327,27 @@ func (ec *executionContext) unmarshalInputUpdateBeanInput(ctx context.Context, o return it, err } it.RemoveBlockedBy = data + case "properties": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("properties")) + data, err := ec.unmarshalOJSON2map(ctx, v) + if err != nil { + return it, err + } + it.Properties = data + case "setProperties": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("setProperties")) + data, err := ec.unmarshalOJSON2map(ctx, v) + if err != nil { + return it, err + } + it.SetProperties = data + case "unsetProperties": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("unsetProperties")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.UnsetProperties = data case "ifMatch": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ifMatch")) data, err := ec.unmarshalOString2ᚖstring(ctx, v) @@ -4347,6 +4437,8 @@ func (ec *executionContext) _Bean(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "properties": + out.Values[i] = ec._Bean_properties(ctx, field, obj) case "parentId": field := field @@ -5619,6 +5711,24 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return res } +func (ec *executionContext) unmarshalOJSON2map(ctx context.Context, v any) (map[string]any, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalMap(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOJSON2map(ctx context.Context, sel ast.SelectionSet, v map[string]any) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalMap(v) + return res +} + func (ec *executionContext) unmarshalOReplaceOperation2ᚕᚖgithubᚗcomᚋhmansᚋbeansᚋinternalᚋgraphᚋmodelᚐReplaceOperationᚄ(ctx context.Context, v any) ([]*model.ReplaceOperation, error) { if v == nil { return nil, nil diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 8e50a440..6feae967 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -90,6 +90,8 @@ type CreateBeanInput struct { BlockedBy []string `json:"blockedBy,omitempty"` // Custom ID prefix (overrides config prefix for this bean) Prefix *string `json:"prefix,omitempty"` + // Custom properties (key-value metadata) + Properties map[string]any `json:"properties,omitempty"` } type Mutation struct { @@ -136,6 +138,12 @@ type UpdateBeanInput struct { AddBlockedBy []string `json:"addBlockedBy,omitempty"` // Remove beans from blocked-by list RemoveBlockedBy []string `json:"removeBlockedBy,omitempty"` + // Replace all custom properties (mutually exclusive with setProperties/unsetProperties) + Properties map[string]any `json:"properties,omitempty"` + // Set or update specific custom properties (merge/upsert) + SetProperties map[string]any `json:"setProperties,omitempty"` + // Remove specific custom property keys + UnsetProperties []string `json:"unsetProperties,omitempty"` // ETag for optimistic concurrency control (optional) IfMatch *string `json:"ifMatch,omitempty"` } diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 165ce67a..7b7902f5 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -1,6 +1,7 @@ # Beans GraphQL Schema scalar Time +scalar JSON type Query { """ @@ -80,6 +81,8 @@ input CreateBeanInput { blockedBy: [String!] "Custom ID prefix (overrides config prefix for this bean)" prefix: String + "Custom properties (key-value metadata)" + properties: JSON } """ @@ -116,6 +119,13 @@ input UpdateBeanInput { "Remove beans from blocked-by list" removeBlockedBy: [String!] + "Replace all custom properties (mutually exclusive with setProperties/unsetProperties)" + properties: JSON + "Set or update specific custom properties (merge/upsert)" + setProperties: JSON + "Remove specific custom property keys" + unsetProperties: [String!] + "ETag for optimistic concurrency control (optional)" ifMatch: String } @@ -176,6 +186,8 @@ type Bean { body: String! "Content hash for optimistic concurrency control" etag: String! + "Custom key-value properties" + properties: JSON # Direct link fields "Parent bean ID (optional, type-restricted)" diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index e1d5da15..9ef30cc0 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -154,6 +154,11 @@ func (r *mutationResolver) CreateBean(ctx context.Context, input model.CreateBea b.BlockedBy = normalizedBlockedBy } + // Handle properties + if len(input.Properties) > 0 { + b.Properties = input.Properties + } + // Handle custom prefix - pre-generate ID if prefix is provided if input.Prefix != nil && *input.Prefix != "" { idLength := 4 // default @@ -187,6 +192,11 @@ func (r *mutationResolver) UpdateBean(ctx context.Context, id string, input mode return nil, fmt.Errorf("cannot specify both tags and addTags/removeTags") } + // Validate properties and setProperties/unsetProperties are mutually exclusive + if input.Properties != nil && (input.SetProperties != nil || input.UnsetProperties != nil) { + return nil, fmt.Errorf("cannot specify both properties and setProperties/unsetProperties") + } + // Update fields if provided if input.Title != nil { b.Title = *input.Title @@ -256,6 +266,24 @@ func (r *mutationResolver) UpdateBean(ctx context.Context, id string, input mode b.Tags = newTags } + // Handle properties + if input.Properties != nil { + // Replace all — normalize empty map to nil + if len(input.Properties) == 0 { + b.Properties = nil + } else { + b.Properties = input.Properties + } + } else if input.SetProperties != nil || input.UnsetProperties != nil { + // Granular set/unset + for k, v := range input.SetProperties { + b.SetProperty(k, v) + } + for _, k := range input.UnsetProperties { + b.UnsetProperty(k) + } + } + // Handle parent relationship if input.Parent != nil { if err := r.validateAndSetParent(b, *input.Parent); err != nil { diff --git a/internal/graph/schema.resolvers_test.go b/internal/graph/schema.resolvers_test.go index 44335932..cd674989 100644 --- a/internal/graph/schema.resolvers_test.go +++ b/internal/graph/schema.resolvers_test.go @@ -2773,3 +2773,176 @@ func TestRemoveBlockingWithETag(t *testing.T) { }) } +func TestPropertiesResolvers(t *testing.T) { + resolver, core := setupTestResolver(t) + ctx := context.Background() + + t.Run("create bean with properties", func(t *testing.T) { + mr := resolver.Mutation() + input := model.CreateBeanInput{ + Title: "Bean With Props", + Properties: map[string]any{"author": "alice", "estimate": 3}, + } + got, err := mr.CreateBean(ctx, input) + if err != nil { + t.Fatalf("CreateBean() error = %v", err) + } + if len(got.Properties) != 2 { + t.Errorf("CreateBean().Properties count = %d, want 2", len(got.Properties)) + } + if got.Properties["author"] != "alice" { + t.Errorf("Properties['author'] = %v, want 'alice'", got.Properties["author"]) + } + }) + + t.Run("update bean replace all properties", func(t *testing.T) { + b := &bean.Bean{ + ID: "props-replace", + Title: "Test", + Status: "todo", + Properties: map[string]any{"old": "value"}, + } + core.Create(b) + + mr := resolver.Mutation() + input := model.UpdateBeanInput{ + Properties: map[string]any{"new": "value", "count": 42}, + } + got, err := mr.UpdateBean(ctx, "props-replace", input) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if len(got.Properties) != 2 { + t.Errorf("Properties count = %d, want 2", len(got.Properties)) + } + if _, ok := got.Properties["old"]; ok { + t.Error("Old property should be replaced") + } + if got.Properties["new"] != "value" { + t.Errorf("Properties['new'] = %v, want 'value'", got.Properties["new"]) + } + }) + + t.Run("update bean set individual properties", func(t *testing.T) { + b := &bean.Bean{ + ID: "props-set", + Title: "Test", + Status: "todo", + Properties: map[string]any{"existing": "keep", "update": "old"}, + } + core.Create(b) + + mr := resolver.Mutation() + input := model.UpdateBeanInput{ + SetProperties: map[string]any{"update": "new", "added": true}, + } + got, err := mr.UpdateBean(ctx, "props-set", input) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if got.Properties["existing"] != "keep" { + t.Errorf("Properties['existing'] = %v, want 'keep'", got.Properties["existing"]) + } + if got.Properties["update"] != "new" { + t.Errorf("Properties['update'] = %v, want 'new'", got.Properties["update"]) + } + if got.Properties["added"] != true { + t.Errorf("Properties['added'] = %v, want true", got.Properties["added"]) + } + }) + + t.Run("update bean unset properties", func(t *testing.T) { + b := &bean.Bean{ + ID: "props-unset", + Title: "Test", + Status: "todo", + Properties: map[string]any{"keep": "yes", "remove": "bye"}, + } + core.Create(b) + + mr := resolver.Mutation() + input := model.UpdateBeanInput{ + UnsetProperties: []string{"remove"}, + } + got, err := mr.UpdateBean(ctx, "props-unset", input) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if got.Properties["keep"] != "yes" { + t.Errorf("Properties['keep'] = %v, want 'yes'", got.Properties["keep"]) + } + if _, ok := got.Properties["remove"]; ok { + t.Error("Property 'remove' should have been unset") + } + }) + + t.Run("unset all properties normalizes to nil", func(t *testing.T) { + b := &bean.Bean{ + ID: "props-unset-all", + Title: "Test", + Status: "todo", + Properties: map[string]any{"only": "one"}, + } + core.Create(b) + + mr := resolver.Mutation() + input := model.UpdateBeanInput{ + UnsetProperties: []string{"only"}, + } + got, err := mr.UpdateBean(ctx, "props-unset-all", input) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if got.Properties != nil { + t.Errorf("Properties should be nil after unsetting all, got %v", got.Properties) + } + }) + + t.Run("properties and setProperties are mutually exclusive", func(t *testing.T) { + b := &bean.Bean{ID: "props-mutex", Title: "Test", Status: "todo"} + core.Create(b) + + mr := resolver.Mutation() + input := model.UpdateBeanInput{ + Properties: map[string]any{"full": "replace"}, + SetProperties: map[string]any{"partial": "set"}, + } + _, err := mr.UpdateBean(ctx, "props-mutex", input) + if err == nil { + t.Error("UpdateBean() should fail when both properties and setProperties provided") + } + if !strings.Contains(err.Error(), "cannot specify both") { + t.Errorf("Error should mention mutual exclusivity, got: %v", err) + } + }) + + t.Run("set and unset in same operation", func(t *testing.T) { + b := &bean.Bean{ + ID: "props-set-unset", + Title: "Test", + Status: "todo", + Properties: map[string]any{"remove": "bye", "keep": "yes"}, + } + core.Create(b) + + mr := resolver.Mutation() + input := model.UpdateBeanInput{ + SetProperties: map[string]any{"add": "new"}, + UnsetProperties: []string{"remove"}, + } + got, err := mr.UpdateBean(ctx, "props-set-unset", input) + if err != nil { + t.Fatalf("UpdateBean() error = %v", err) + } + if len(got.Properties) != 2 { + t.Errorf("Properties count = %d, want 2", len(got.Properties)) + } + if got.Properties["keep"] != "yes" { + t.Errorf("Properties['keep'] = %v, want 'yes'", got.Properties["keep"]) + } + if got.Properties["add"] != "new" { + t.Errorf("Properties['add'] = %v, want 'new'", got.Properties["add"]) + } + }) +} +