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 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/max
2026-01-14 21:09:48 -08:00
committed by Steve Yegge
parent 42d9890e5c
commit 0bf68de517
2 changed files with 516 additions and 0 deletions

View File

@@ -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-<name>
func GroupBeadID(name string) string {
return "gt-group-" + name
}
// CreateGroupBead creates a group bead for mail distribution.
// The ID format is: gt-group-<name> (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
}

View File

@@ -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)
}
}
}