From 346a283cc4c8baf61cd90b52a94f45a97458c2c0 Mon Sep 17 00:00:00 2001 From: toecutter Date: Fri, 2 Jan 2026 00:20:23 -0800 Subject: [PATCH] feat(mail): Add gt mail announces command to list and read bulletin boards (gt-27bzi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add announces subcommand to internal/cmd/mail.go that provides: - gt mail announces: Lists all announce channels from messaging.json - gt mail announces : Reads messages from a specific channel The command queries beads for messages with announce_channel label and displays them in reverse chronological order. Messages are NOT marked as read or removed, preserving bulletin board semantics. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mail.go | 269 ++++++++++++++++++++++++++++++++++++++ internal/cmd/mail_test.go | 114 ++++++++++++++++ 2 files changed, 383 insertions(+) diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 7af73159..2138d982 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -52,6 +52,9 @@ var ( mailSearchBody bool mailSearchArchive bool mailSearchJSON bool + + // Announces flags + mailAnnouncesJSON bool ) var mailCmd = &cobra.Command{ @@ -354,6 +357,38 @@ Examples: RunE: runMailSearch, } +var mailAnnouncesCmd = &cobra.Command{ + Use: "announces [channel]", + Short: "List or read announce channels", + Long: `List available announce channels or read messages from a channel. + +SYNTAX: + gt mail announces # List all announce channels + gt mail announces # Read messages from a channel + +Announce channels are bulletin boards defined in ~/gt/config/messaging.json. +Messages are broadcast to readers and persist until retention limit is reached. +Unlike regular mail, announce messages are NOT removed when read. + +BEHAVIOR for 'gt mail announces': +- Loads messaging.json +- Lists all announce channel names +- Shows reader patterns and retain_count for each + +BEHAVIOR for 'gt mail announces ': +- Validates channel exists +- Queries beads for messages with announce_channel= +- Displays in reverse chronological order (newest first) +- Does NOT mark as read or remove messages + +Examples: + gt mail announces # List all channels + gt mail announces alerts # Read messages from 'alerts' channel + gt mail announces --json # List channels as JSON`, + Args: cobra.MaximumNArgs(1), + RunE: runMailAnnounces, +} + func init() { // Send flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") @@ -400,6 +435,9 @@ func init() { mailSearchCmd.Flags().BoolVar(&mailSearchArchive, "archive", false, "Include archived messages") mailSearchCmd.Flags().BoolVar(&mailSearchJSON, "json", false, "Output as JSON") + // Announces flags + mailAnnouncesCmd.Flags().BoolVar(&mailAnnouncesJSON, "json", false, "Output as JSON") + // Add subcommands mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailInboxCmd) @@ -414,6 +452,7 @@ func init() { mailCmd.AddCommand(mailReleaseCmd) mailCmd.AddCommand(mailClearCmd) mailCmd.AddCommand(mailSearchCmd) + mailCmd.AddCommand(mailAnnouncesCmd) rootCmd.AddCommand(mailCmd) } @@ -1702,3 +1741,233 @@ func runMailSearch(cmd *cobra.Command, args []string) error { return nil } + +// runMailAnnounces lists announce channels or reads messages from a channel. +func runMailAnnounces(cmd *cobra.Command, args []string) error { + // Find workspace + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Load messaging config + configPath := config.MessagingConfigPath(townRoot) + cfg, err := config.LoadMessagingConfig(configPath) + if err != nil { + return fmt.Errorf("loading messaging config: %w", err) + } + + // If no channel specified, list all channels + if len(args) == 0 { + return listAnnounceChannels(cfg) + } + + // Read messages from specified channel + channelName := args[0] + return readAnnounceChannel(townRoot, cfg, channelName) +} + +// listAnnounceChannels lists all announce channels and their configuration. +func listAnnounceChannels(cfg *config.MessagingConfig) error { + if cfg.Announces == nil || len(cfg.Announces) == 0 { + if mailAnnouncesJSON { + fmt.Println("[]") + return nil + } + fmt.Printf("%s No announce channels configured\n", style.Dim.Render("○")) + return nil + } + + // JSON output + if mailAnnouncesJSON { + type channelInfo struct { + Name string `json:"name"` + Readers []string `json:"readers"` + RetainCount int `json:"retain_count"` + } + var channels []channelInfo + for name, annCfg := range cfg.Announces { + channels = append(channels, channelInfo{ + Name: name, + Readers: annCfg.Readers, + RetainCount: annCfg.RetainCount, + }) + } + // Sort by name for consistent output + sort.Slice(channels, func(i, j int) bool { + return channels[i].Name < channels[j].Name + }) + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(channels) + } + + // Human-readable output + fmt.Printf("%s Announce Channels (%d)\n\n", style.Bold.Render("📢"), len(cfg.Announces)) + + // Sort channel names for consistent output + var names []string + for name := range cfg.Announces { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + annCfg := cfg.Announces[name] + retainStr := "unlimited" + if annCfg.RetainCount > 0 { + retainStr = fmt.Sprintf("%d messages", annCfg.RetainCount) + } + fmt.Printf(" %s %s\n", style.Bold.Render("●"), name) + fmt.Printf(" Readers: %s\n", strings.Join(annCfg.Readers, ", ")) + fmt.Printf(" Retain: %s\n", style.Dim.Render(retainStr)) + } + + return nil +} + +// readAnnounceChannel reads messages from an announce channel. +func readAnnounceChannel(townRoot string, cfg *config.MessagingConfig, channelName string) error { + // Validate channel exists + if cfg.Announces == nil { + return fmt.Errorf("no announce channels configured") + } + _, ok := cfg.Announces[channelName] + if !ok { + return fmt.Errorf("unknown announce channel: %s", channelName) + } + + // Query beads for messages with announce_channel= + messages, err := listAnnounceMessages(townRoot, channelName) + if err != nil { + return fmt.Errorf("listing announce messages: %w", err) + } + + // JSON output + if mailAnnouncesJSON { + // Ensure empty array instead of null for JSON + if messages == nil { + messages = []announceMessage{} + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(messages) + } + + // Human-readable output + fmt.Printf("%s Channel: %s (%d messages)\n\n", + style.Bold.Render("📢"), channelName, len(messages)) + + if len(messages) == 0 { + fmt.Printf(" %s\n", style.Dim.Render("(no messages)")) + return nil + } + + for _, msg := range messages { + priorityMarker := "" + if msg.Priority <= 1 { + priorityMarker = " " + style.Bold.Render("!") + } + + fmt.Printf(" %s %s%s\n", style.Bold.Render("●"), msg.Title, priorityMarker) + fmt.Printf(" %s from %s\n", + style.Dim.Render(msg.ID), + msg.From) + fmt.Printf(" %s\n", + style.Dim.Render(msg.Created.Format("2006-01-02 15:04"))) + if msg.Description != "" { + // Show first line of description as preview + lines := strings.SplitN(msg.Description, "\n", 2) + preview := lines[0] + if len(preview) > 80 { + preview = preview[:77] + "..." + } + fmt.Printf(" %s\n", style.Dim.Render(preview)) + } + } + + return nil +} + +// announceMessage represents a message in an announce channel. +type announceMessage struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + From string `json:"from"` + Created time.Time `json:"created"` + Priority int `json:"priority"` +} + +// listAnnounceMessages lists messages from an announce channel. +func listAnnounceMessages(townRoot, channelName string) ([]announceMessage, error) { + beadsDir := filepath.Join(townRoot, ".beads") + + // Query for messages with label announce_channel: + // Messages are stored with this label when sent via sendToAnnounce() + args := []string{"list", + "--type", "message", + "--label", "announce_channel:" + channelName, + "--sort", "-created", // Newest first + "--limit", "0", // No limit + "--json", + } + + cmd := exec.Command("bd", args...) + cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return nil, fmt.Errorf("%s", errMsg) + } + return nil, err + } + + // Parse JSON output + var issues []struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Labels []string `json:"labels"` + CreatedAt time.Time `json:"created_at"` + Priority int `json:"priority"` + } + + output := strings.TrimSpace(stdout.String()) + if output == "" || output == "[]" { + return nil, nil + } + + if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { + return nil, fmt.Errorf("parsing bd output: %w", err) + } + + // Convert to announceMessage, extracting 'from' from labels + var messages []announceMessage + for _, issue := range issues { + msg := announceMessage{ + ID: issue.ID, + Title: issue.Title, + Description: issue.Description, + Created: issue.CreatedAt, + Priority: issue.Priority, + } + + // Extract 'from' from labels (format: "from:address") + for _, label := range issue.Labels { + if strings.HasPrefix(label, "from:") { + msg.From = strings.TrimPrefix(label, "from:") + break + } + } + + messages = append(messages, msg) + } + + return messages, nil +} diff --git a/internal/cmd/mail_test.go b/internal/cmd/mail_test.go index 77de999d..7f302532 100644 --- a/internal/cmd/mail_test.go +++ b/internal/cmd/mail_test.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" "testing" + + "github.com/steveyegge/gastown/internal/config" ) func TestMatchWorkerPattern(t *testing.T) { @@ -267,3 +269,115 @@ func validateRelease(msgInfo *messageInfo, caller string) error { return nil } + +// TestMailAnnounces tests the announces command functionality. +func TestMailAnnounces(t *testing.T) { + t.Run("listAnnounceChannels with nil config", func(t *testing.T) { + // Test with nil announces map + cfg := &config.MessagingConfig{ + Announces: nil, + } + + // Reset flag to default + mailAnnouncesJSON = false + + // This should not panic and should handle nil gracefully + // We can't easily capture stdout in unit tests, but we can verify no panic + err := listAnnounceChannels(cfg) + if err != nil { + t.Errorf("listAnnounceChannels with nil announces should not error: %v", err) + } + }) + + t.Run("listAnnounceChannels with empty config", func(t *testing.T) { + cfg := &config.MessagingConfig{ + Announces: make(map[string]config.AnnounceConfig), + } + + mailAnnouncesJSON = false + err := listAnnounceChannels(cfg) + if err != nil { + t.Errorf("listAnnounceChannels with empty announces should not error: %v", err) + } + }) + + t.Run("readAnnounceChannel validates channel exists", func(t *testing.T) { + cfg := &config.MessagingConfig{ + Announces: map[string]config.AnnounceConfig{ + "alerts": { + Readers: []string{"@town"}, + RetainCount: 100, + }, + }, + } + + // Test with unknown channel + err := readAnnounceChannel("/tmp", cfg, "nonexistent") + if err == nil { + t.Error("readAnnounceChannel should error for unknown channel") + } + if !strings.Contains(err.Error(), "unknown announce channel") { + t.Errorf("error should mention 'unknown announce channel', got: %v", err) + } + }) + + t.Run("readAnnounceChannel errors on nil announces", func(t *testing.T) { + cfg := &config.MessagingConfig{ + Announces: nil, + } + + err := readAnnounceChannel("/tmp", cfg, "alerts") + if err == nil { + t.Error("readAnnounceChannel should error for nil announces") + } + if !strings.Contains(err.Error(), "no announce channels configured") { + t.Errorf("error should mention 'no announce channels configured', got: %v", err) + } + }) +} + +// TestAnnounceMessageParsing tests parsing of announce messages from beads output. +func TestAnnounceMessageParsing(t *testing.T) { + tests := []struct { + name string + labels []string + want string + }{ + { + name: "extracts from label", + labels: []string{"from:mayor/", "announce_channel:alerts"}, + want: "mayor/", + }, + { + name: "extracts from with rig path", + labels: []string{"announce_channel:alerts", "from:gastown/witness"}, + want: "gastown/witness", + }, + { + name: "no from label", + labels: []string{"announce_channel:alerts"}, + want: "", + }, + { + name: "empty labels", + labels: []string{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the label extraction logic from listAnnounceMessages + var from string + for _, label := range tt.labels { + if strings.HasPrefix(label, "from:") { + from = strings.TrimPrefix(label, "from:") + break + } + } + if from != tt.want { + t.Errorf("extracting from label: got %q, want %q", from, tt.want) + } + }) + } +}