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:
committed by
Steve Yegge
parent
839fa19e90
commit
b3b980fd79
354
internal/cmd/mail_group.go
Normal file
354
internal/cmd/mail_group.go
Normal 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)
|
||||
}
|
||||
73
internal/cmd/mail_group_test.go
Normal file
73
internal/cmd/mail_group_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user