Skip to content
Open
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
17 changes: 15 additions & 2 deletions .beans/beans-digu--support-custom-properties-on-beans.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 <id> --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)
11 changes: 11 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var (
createBlocking []string
createBlockedBy []string
createPrefix string
createSet []string
createJSON bool
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions cmd/properties.go
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 92 additions & 0 deletions cmd/properties_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
26 changes: 26 additions & 0 deletions cmd/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"sort"
"strings"

"github.com/charmbracelet/glamour"
Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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")
Expand Down
23 changes: 21 additions & 2 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ var (
updateRemoveBlockedBy []string
updateTag []string
updateRemoveTag []string
updateSet []string
updateUnset []string
updateIfMatch string
updateJSON bool
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions gqlgen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading