feat(mail): add group management commands

Add gt mail group subcommands:
- gt mail group list - list all groups
- gt mail group show <name> - show group details
- gt mail group create <name> [members...] - create new group
- gt mail group add <name> <member> - add member
- gt mail group remove <name> <member> - remove member
- gt mail group delete <name> - delete group

Includes validation for group names and member patterns.
Supports direct addresses, wildcards, @-patterns, and nested groups.

Part of gt-xfqh1e.7 (group commands task).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/max
2026-01-14 21:18:20 -08:00
committed by Steve Yegge
parent 839fa19e90
commit b3b980fd79
2 changed files with 427 additions and 0 deletions

354
internal/cmd/mail_group.go Normal file
View File

@@ -0,0 +1,354 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/workspace"
)
// Group command flags
var (
groupJSON bool
groupMembers []string
)
var mailGroupCmd = &cobra.Command{
Use: "group",
Short: "Manage mail groups",
Long: `Create and manage mail distribution groups.
Groups are named collections of addresses used for mail distribution.
Members can be:
- Direct addresses (gastown/crew/max)
- Patterns (*/witness, gastown/*)
- Other group names (nested groups)
Examples:
gt mail group list # List all groups
gt mail group show ops-team # Show group members
gt mail group create ops-team gastown/witness gastown/crew/max
gt mail group add ops-team deacon/
gt mail group remove ops-team gastown/witness
gt mail group delete ops-team`,
RunE: requireSubcommand,
}
var groupListCmd = &cobra.Command{
Use: "list",
Short: "List all groups",
Long: "List all mail distribution groups.",
Args: cobra.NoArgs,
RunE: runGroupList,
}
var groupShowCmd = &cobra.Command{
Use: "show <name>",
Short: "Show group details",
Long: "Display the members and metadata for a group.",
Args: cobra.ExactArgs(1),
RunE: runGroupShow,
}
var groupCreateCmd = &cobra.Command{
Use: "create <name> [members...]",
Short: "Create a new group",
Long: `Create a new mail distribution group.
Members can be specified as positional arguments or with --member flags.
Examples:
gt mail group create ops-team gastown/witness gastown/crew/max
gt mail group create ops-team --member gastown/witness --member gastown/crew/max`,
Args: cobra.MinimumNArgs(1),
RunE: runGroupCreate,
}
var groupAddCmd = &cobra.Command{
Use: "add <name> <member>",
Short: "Add member to group",
Long: "Add a new member to an existing group.",
Args: cobra.ExactArgs(2),
RunE: runGroupAdd,
}
var groupRemoveCmd = &cobra.Command{
Use: "remove <name> <member>",
Short: "Remove member from group",
Long: "Remove a member from an existing group.",
Args: cobra.ExactArgs(2),
RunE: runGroupRemove,
}
var groupDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Delete a group",
Long: "Permanently delete a mail distribution group.",
Args: cobra.ExactArgs(1),
RunE: runGroupDelete,
}
func init() {
// List flags
groupListCmd.Flags().BoolVar(&groupJSON, "json", false, "Output as JSON")
// Show flags
groupShowCmd.Flags().BoolVar(&groupJSON, "json", false, "Output as JSON")
// Create flags
groupCreateCmd.Flags().StringArrayVar(&groupMembers, "member", nil, "Member to add (repeatable)")
// Add subcommands
mailGroupCmd.AddCommand(groupListCmd)
mailGroupCmd.AddCommand(groupShowCmd)
mailGroupCmd.AddCommand(groupCreateCmd)
mailGroupCmd.AddCommand(groupAddCmd)
mailGroupCmd.AddCommand(groupRemoveCmd)
mailGroupCmd.AddCommand(groupDeleteCmd)
mailCmd.AddCommand(mailGroupCmd)
}
func runGroupList(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
b := beads.New(townRoot)
groups, err := b.ListGroupBeads()
if err != nil {
return fmt.Errorf("listing groups: %w", err)
}
if groupJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(groups)
}
if len(groups) == 0 {
fmt.Println("No groups defined.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tMEMBERS\tCREATED BY")
for name, fields := range groups {
memberCount := len(fields.Members)
memberStr := fmt.Sprintf("%d member(s)", memberCount)
if memberCount <= 3 {
memberStr = strings.Join(fields.Members, ", ")
}
fmt.Fprintf(w, "%s\t%s\t%s\n", name, memberStr, fields.CreatedBy)
}
return w.Flush()
}
func runGroupShow(cmd *cobra.Command, args []string) error {
name := args[0]
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
b := beads.New(townRoot)
issue, fields, err := b.GetGroupBead(name)
if err != nil {
return fmt.Errorf("getting group: %w", err)
}
if issue == nil {
return fmt.Errorf("group not found: %s", name)
}
if groupJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(fields)
}
fmt.Printf("Group: %s\n", fields.Name)
fmt.Printf("Created by: %s\n", fields.CreatedBy)
if fields.CreatedAt != "" {
fmt.Printf("Created at: %s\n", fields.CreatedAt)
}
fmt.Println()
fmt.Println("Members:")
if len(fields.Members) == 0 {
fmt.Println(" (no members)")
} else {
for _, m := range fields.Members {
fmt.Printf(" - %s\n", m)
}
}
return nil
}
func runGroupCreate(cmd *cobra.Command, args []string) error {
name := args[0]
members := args[1:] // Positional members
// Add --member flag values
members = append(members, groupMembers...)
if !isValidGroupName(name) {
return fmt.Errorf("invalid group name %q: must be alphanumeric with dashes/underscores", name)
}
// Validate member patterns
for _, m := range members {
if !isValidMemberPattern(m) {
return fmt.Errorf("invalid member pattern: %s", m)
}
}
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Detect creator
createdBy := os.Getenv("BD_ACTOR")
if createdBy == "" {
createdBy = "unknown"
}
b := beads.New(townRoot)
// Check if group already exists
existing, _, err := b.GetGroupBead(name)
if err != nil {
return err
}
if existing != nil {
return fmt.Errorf("group already exists: %s", name)
}
_, err = b.CreateGroupBead(name, members, createdBy)
if err != nil {
return fmt.Errorf("creating group: %w", err)
}
fmt.Printf("Created group %q with %d member(s)\n", name, len(members))
return nil
}
func runGroupAdd(cmd *cobra.Command, args []string) error {
name := args[0]
member := args[1]
if !isValidMemberPattern(member) {
return fmt.Errorf("invalid member pattern: %s", member)
}
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
b := beads.New(townRoot)
if err := b.AddGroupMember(name, member); err != nil {
return fmt.Errorf("adding member: %w", err)
}
fmt.Printf("Added %q to group %q\n", member, name)
return nil
}
func runGroupRemove(cmd *cobra.Command, args []string) error {
name := args[0]
member := args[1]
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
b := beads.New(townRoot)
if err := b.RemoveGroupMember(name, member); err != nil {
return fmt.Errorf("removing member: %w", err)
}
fmt.Printf("Removed %q from group %q\n", member, name)
return nil
}
func runGroupDelete(cmd *cobra.Command, args []string) error {
name := args[0]
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
b := beads.New(townRoot)
// Check if group exists
existing, _, err := b.GetGroupBead(name)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("group not found: %s", name)
}
if err := b.DeleteGroupBead(name); err != nil {
return fmt.Errorf("deleting group: %w", err)
}
fmt.Printf("Deleted group %q\n", name)
return nil
}
// isValidGroupName checks if a group name is valid.
// Group names must be alphanumeric with dashes and underscores.
func isValidGroupName(name string) bool {
if name == "" {
return false
}
for _, r := range name {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
// isValidMemberPattern checks if a member pattern is syntactically valid.
// Valid patterns include:
// - Direct addresses: gastown/crew/max, mayor/, deacon/
// - Wildcards: */witness, gastown/*, gastown/crew/*
// - Special patterns: @town, @crew, @witnesses
// - Group names: ops-team
func isValidMemberPattern(pattern string) bool {
if pattern == "" {
return false
}
// @ patterns are valid
if strings.HasPrefix(pattern, "@") {
return len(pattern) > 1
}
// Path patterns with wildcards
if strings.Contains(pattern, "/") {
// Must have valid path segments
parts := strings.Split(pattern, "/")
for _, p := range parts {
if p == "" && pattern[len(pattern)-1] != '/' {
return false // Empty segment (except trailing /)
}
}
return true
}
// Simple name (group reference) - use same validation as group names
return isValidGroupName(pattern)
}

View File

@@ -0,0 +1,73 @@
package cmd
import "testing"
func TestIsValidGroupName(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"ops-team", true},
{"all_witnesses", true},
{"team123", true},
{"A", true},
{"abc", true},
{"my-cool-group", true},
// Invalid
{"", false},
{"with spaces", false},
{"with.dots", false},
{"@team", false},
{"group/name", false},
{"team!", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isValidGroupName(tt.name); got != tt.want {
t.Errorf("isValidGroupName(%q) = %v, want %v", tt.name, got, tt.want)
}
})
}
}
func TestIsValidMemberPattern(t *testing.T) {
tests := []struct {
pattern string
want bool
}{
// Direct addresses
{"gastown/crew/max", true},
{"mayor/", true},
{"deacon/", true},
{"gastown/witness", true},
// Wildcard patterns
{"*/witness", true},
{"gastown/*", true},
{"gastown/crew/*", true},
// Special patterns
{"@town", true},
{"@crew", true},
{"@witnesses", true},
{"@rig/gastown", true},
// Group names
{"ops-team", true},
{"all_witnesses", true},
// Invalid
{"", false},
{"@", false},
}
for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
if got := isValidMemberPattern(tt.pattern); got != tt.want {
t.Errorf("isValidMemberPattern(%q) = %v, want %v", tt.pattern, got, tt.want)
}
})
}
}