feat(mail): Add gt mail announces command to list and read bulletin boards (gt-27bzi)
Add announces subcommand to internal/cmd/mail.go that provides: - gt mail announces: Lists all announce channels from messaging.json - gt mail announces <channel>: 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <channel> # 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 <channel>':
|
||||
- Validates channel exists
|
||||
- Queries beads for messages with announce_channel=<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=<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:<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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user