From 0bf68de517974e27af608f0b7952e8356ca0af23 Mon Sep 17 00:00:00 2001 From: gastown/crew/max Date: Wed, 14 Jan 2026 21:09:48 -0800 Subject: [PATCH] feat(beads): add group bead type for beads-native messaging Add type=group to beads schema for mail distribution groups. Fields: - name: unique group identifier - members: addresses, patterns, or group names (can nest) - created_by: provenance tracking - created_at: timestamp Groups support: - Direct addresses (gastown/crew/max) - Patterns (*/witness, @crew) - Nested groups (members can reference other groups) Part of gt-xfqh1e epic (beads-native messaging). Co-Authored-By: Claude Opus 4.5 --- internal/beads/beads_group.go | 307 +++++++++++++++++++++++++++++ internal/beads/beads_group_test.go | 209 ++++++++++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 internal/beads/beads_group.go create mode 100644 internal/beads/beads_group_test.go diff --git a/internal/beads/beads_group.go b/internal/beads/beads_group.go new file mode 100644 index 00000000..24b07004 --- /dev/null +++ b/internal/beads/beads_group.go @@ -0,0 +1,307 @@ +// Package beads provides group bead management for beads-native messaging. +// Groups are named collections of addresses used for mail distribution. +package beads + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" +) + +// GroupFields holds structured fields for group beads. +// These are stored as "key: value" lines in the description. +type GroupFields struct { + Name string // Unique group name (e.g., "ops-team", "all-witnesses") + Members []string // Addresses, patterns, or group names (can nest) + CreatedBy string // Who created the group + CreatedAt string // ISO 8601 timestamp +} + +// FormatGroupDescription creates a description string from group fields. +func FormatGroupDescription(title string, fields *GroupFields) string { + if fields == nil { + return title + } + + var lines []string + lines = append(lines, title) + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("name: %s", fields.Name)) + + // Members stored as comma-separated list + if len(fields.Members) > 0 { + lines = append(lines, fmt.Sprintf("members: %s", strings.Join(fields.Members, ","))) + } else { + lines = append(lines, "members: null") + } + + if fields.CreatedBy != "" { + lines = append(lines, fmt.Sprintf("created_by: %s", fields.CreatedBy)) + } else { + lines = append(lines, "created_by: null") + } + + if fields.CreatedAt != "" { + lines = append(lines, fmt.Sprintf("created_at: %s", fields.CreatedAt)) + } else { + lines = append(lines, "created_at: null") + } + + return strings.Join(lines, "\n") +} + +// ParseGroupFields extracts group fields from an issue's description. +func ParseGroupFields(description string) *GroupFields { + fields := &GroupFields{} + + for _, line := range strings.Split(description, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + colonIdx := strings.Index(line, ":") + if colonIdx == -1 { + continue + } + + key := strings.TrimSpace(line[:colonIdx]) + value := strings.TrimSpace(line[colonIdx+1:]) + if value == "null" || value == "" { + value = "" + } + + switch strings.ToLower(key) { + case "name": + fields.Name = value + case "members": + if value != "" { + // Parse comma-separated members + for _, m := range strings.Split(value, ",") { + m = strings.TrimSpace(m) + if m != "" { + fields.Members = append(fields.Members, m) + } + } + } + case "created_by": + fields.CreatedBy = value + case "created_at": + fields.CreatedAt = value + } + } + + return fields +} + +// GroupBeadID returns the bead ID for a group name. +// Format: gt-group- +func GroupBeadID(name string) string { + return "gt-group-" + name +} + +// CreateGroupBead creates a group bead for mail distribution. +// The ID format is: gt-group- (e.g., gt-group-ops-team) +// The created_by field is populated from BD_ACTOR env var for provenance tracking. +func (b *Beads) CreateGroupBead(name string, members []string, createdBy string) (*Issue, error) { + id := GroupBeadID(name) + title := fmt.Sprintf("Group: %s", name) + + fields := &GroupFields{ + Name: name, + Members: members, + CreatedBy: createdBy, + } + + description := FormatGroupDescription(title, fields) + + args := []string{"create", "--json", + "--id=" + id, + "--title=" + title, + "--description=" + description, + "--type=task", // Groups use task type with gt:group label + "--labels=gt:group", + } + + // Default actor from BD_ACTOR env var for provenance tracking + if actor := os.Getenv("BD_ACTOR"); actor != "" { + args = append(args, "--actor="+actor) + } + + out, err := b.run(args...) + if err != nil { + return nil, err + } + + var issue Issue + if err := json.Unmarshal(out, &issue); err != nil { + return nil, fmt.Errorf("parsing bd create output: %w", err) + } + + return &issue, nil +} + +// GetGroupBead retrieves a group bead by name. +// Returns nil, nil if not found. +func (b *Beads) GetGroupBead(name string) (*Issue, *GroupFields, error) { + id := GroupBeadID(name) + issue, err := b.Show(id) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, nil, nil + } + return nil, nil, err + } + + if !HasLabel(issue, "gt:group") { + return nil, nil, fmt.Errorf("bead %s is not a group bead (missing gt:group label)", id) + } + + fields := ParseGroupFields(issue.Description) + return issue, fields, nil +} + +// GetGroupByID retrieves a group bead by its full ID. +// Returns nil, nil if not found. +func (b *Beads) GetGroupByID(id string) (*Issue, *GroupFields, error) { + issue, err := b.Show(id) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, nil, nil + } + return nil, nil, err + } + + if !HasLabel(issue, "gt:group") { + return nil, nil, fmt.Errorf("bead %s is not a group bead (missing gt:group label)", id) + } + + fields := ParseGroupFields(issue.Description) + return issue, fields, nil +} + +// UpdateGroupMembers updates the members list for a group. +func (b *Beads) UpdateGroupMembers(name string, members []string) error { + issue, fields, err := b.GetGroupBead(name) + if err != nil { + return err + } + if issue == nil { + return fmt.Errorf("group %q not found", name) + } + + fields.Members = members + description := FormatGroupDescription(issue.Title, fields) + + return b.Update(issue.ID, UpdateOptions{Description: &description}) +} + +// AddGroupMember adds a member to a group if not already present. +func (b *Beads) AddGroupMember(name string, member string) error { + issue, fields, err := b.GetGroupBead(name) + if err != nil { + return err + } + if issue == nil { + return fmt.Errorf("group %q not found", name) + } + + // Check if already a member + for _, m := range fields.Members { + if m == member { + return nil // Already a member + } + } + + fields.Members = append(fields.Members, member) + description := FormatGroupDescription(issue.Title, fields) + + return b.Update(issue.ID, UpdateOptions{Description: &description}) +} + +// RemoveGroupMember removes a member from a group. +func (b *Beads) RemoveGroupMember(name string, member string) error { + issue, fields, err := b.GetGroupBead(name) + if err != nil { + return err + } + if issue == nil { + return fmt.Errorf("group %q not found", name) + } + + // Filter out the member + var newMembers []string + for _, m := range fields.Members { + if m != member { + newMembers = append(newMembers, m) + } + } + + fields.Members = newMembers + description := FormatGroupDescription(issue.Title, fields) + + return b.Update(issue.ID, UpdateOptions{Description: &description}) +} + +// DeleteGroupBead permanently deletes a group bead. +func (b *Beads) DeleteGroupBead(name string) error { + id := GroupBeadID(name) + _, err := b.run("delete", id, "--hard", "--force") + return err +} + +// ListGroupBeads returns all group beads. +func (b *Beads) ListGroupBeads() (map[string]*GroupFields, error) { + out, err := b.run("list", "--label=gt:group", "--json") + if err != nil { + return nil, err + } + + var issues []*Issue + if err := json.Unmarshal(out, &issues); err != nil { + return nil, fmt.Errorf("parsing bd list output: %w", err) + } + + result := make(map[string]*GroupFields, len(issues)) + for _, issue := range issues { + fields := ParseGroupFields(issue.Description) + if fields.Name != "" { + result[fields.Name] = fields + } + } + + return result, nil +} + +// LookupGroupByName finds a group by its name field (not by ID). +// This is used for address resolution where we may not know the full bead ID. +func (b *Beads) LookupGroupByName(name string) (*Issue, *GroupFields, error) { + // First try direct lookup by standard ID format + issue, fields, err := b.GetGroupBead(name) + if err != nil { + return nil, nil, err + } + if issue != nil { + return issue, fields, nil + } + + // If not found by ID, search all groups by name field + groups, err := b.ListGroupBeads() + if err != nil { + return nil, nil, err + } + + if fields, ok := groups[name]; ok { + // Found by name, now get the full issue + id := GroupBeadID(name) + issue, err := b.Show(id) + if err != nil { + return nil, nil, err + } + return issue, fields, nil + } + + return nil, nil, nil // Not found +} diff --git a/internal/beads/beads_group_test.go b/internal/beads/beads_group_test.go new file mode 100644 index 00000000..2fbec1b7 --- /dev/null +++ b/internal/beads/beads_group_test.go @@ -0,0 +1,209 @@ +package beads + +import ( + "strings" + "testing" +) + +func TestFormatGroupDescription(t *testing.T) { + tests := []struct { + name string + title string + fields *GroupFields + want []string // Lines that should be present + }{ + { + name: "basic group", + title: "Group: ops-team", + fields: &GroupFields{ + Name: "ops-team", + Members: []string{"gastown/crew/max", "gastown/witness"}, + CreatedBy: "human", + CreatedAt: "2024-01-15T10:00:00Z", + }, + want: []string{ + "Group: ops-team", + "name: ops-team", + "members: gastown/crew/max,gastown/witness", + "created_by: human", + "created_at: 2024-01-15T10:00:00Z", + }, + }, + { + name: "empty members", + title: "Group: empty", + fields: &GroupFields{ + Name: "empty", + Members: nil, + CreatedBy: "admin", + }, + want: []string{ + "name: empty", + "members: null", + "created_by: admin", + }, + }, + { + name: "patterns in members", + title: "Group: all-witnesses", + fields: &GroupFields{ + Name: "all-witnesses", + Members: []string{"*/witness", "@crew"}, + }, + want: []string{ + "members: */witness,@crew", + }, + }, + { + name: "nil fields", + title: "Just a title", + fields: nil, + want: []string{"Just a title"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatGroupDescription(tt.title, tt.fields) + for _, line := range tt.want { + if !strings.Contains(got, line) { + t.Errorf("FormatGroupDescription() missing line %q\ngot:\n%s", line, got) + } + } + }) + } +} + +func TestParseGroupFields(t *testing.T) { + tests := []struct { + name string + description string + want *GroupFields + }{ + { + name: "full group", + description: `Group: ops-team + +name: ops-team +members: gastown/crew/max,gastown/witness,*/refinery +created_by: human +created_at: 2024-01-15T10:00:00Z`, + want: &GroupFields{ + Name: "ops-team", + Members: []string{"gastown/crew/max", "gastown/witness", "*/refinery"}, + CreatedBy: "human", + CreatedAt: "2024-01-15T10:00:00Z", + }, + }, + { + name: "null members", + description: `Group: empty + +name: empty +members: null +created_by: admin`, + want: &GroupFields{ + Name: "empty", + Members: nil, + CreatedBy: "admin", + }, + }, + { + name: "single member", + description: `name: solo +members: gastown/crew/max`, + want: &GroupFields{ + Name: "solo", + Members: []string{"gastown/crew/max"}, + }, + }, + { + name: "empty description", + description: "", + want: &GroupFields{}, + }, + { + name: "members with spaces", + description: `name: spaced +members: a, b , c`, + want: &GroupFields{ + Name: "spaced", + Members: []string{"a", "b", "c"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseGroupFields(tt.description) + if got.Name != tt.want.Name { + t.Errorf("Name = %q, want %q", got.Name, tt.want.Name) + } + if got.CreatedBy != tt.want.CreatedBy { + t.Errorf("CreatedBy = %q, want %q", got.CreatedBy, tt.want.CreatedBy) + } + if got.CreatedAt != tt.want.CreatedAt { + t.Errorf("CreatedAt = %q, want %q", got.CreatedAt, tt.want.CreatedAt) + } + if len(got.Members) != len(tt.want.Members) { + t.Errorf("Members count = %d, want %d", len(got.Members), len(tt.want.Members)) + } else { + for i, m := range got.Members { + if m != tt.want.Members[i] { + t.Errorf("Members[%d] = %q, want %q", i, m, tt.want.Members[i]) + } + } + } + }) + } +} + +func TestGroupBeadID(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"ops-team", "gt-group-ops-team"}, + {"all", "gt-group-all"}, + {"crew-leads", "gt-group-crew-leads"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GroupBeadID(tt.name); got != tt.want { + t.Errorf("GroupBeadID(%q) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +func TestRoundTrip(t *testing.T) { + // Test that Format -> Parse preserves data + original := &GroupFields{ + Name: "test-group", + Members: []string{"gastown/crew/max", "*/witness", "@town"}, + CreatedBy: "tester", + CreatedAt: "2024-01-15T12:00:00Z", + } + + description := FormatGroupDescription("Group: test-group", original) + parsed := ParseGroupFields(description) + + if parsed.Name != original.Name { + t.Errorf("Name: got %q, want %q", parsed.Name, original.Name) + } + if parsed.CreatedBy != original.CreatedBy { + t.Errorf("CreatedBy: got %q, want %q", parsed.CreatedBy, original.CreatedBy) + } + if parsed.CreatedAt != original.CreatedAt { + t.Errorf("CreatedAt: got %q, want %q", parsed.CreatedAt, original.CreatedAt) + } + if len(parsed.Members) != len(original.Members) { + t.Fatalf("Members count: got %d, want %d", len(parsed.Members), len(original.Members)) + } + for i, m := range original.Members { + if parsed.Members[i] != m { + t.Errorf("Members[%d]: got %q, want %q", i, parsed.Members[i], m) + } + } +}