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
|
mailSearchBody bool
|
||||||
mailSearchArchive bool
|
mailSearchArchive bool
|
||||||
mailSearchJSON bool
|
mailSearchJSON bool
|
||||||
|
|
||||||
|
// Announces flags
|
||||||
|
mailAnnouncesJSON bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var mailCmd = &cobra.Command{
|
var mailCmd = &cobra.Command{
|
||||||
@@ -354,6 +357,38 @@ Examples:
|
|||||||
RunE: runMailSearch,
|
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() {
|
func init() {
|
||||||
// Send flags
|
// Send flags
|
||||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
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(&mailSearchArchive, "archive", false, "Include archived messages")
|
||||||
mailSearchCmd.Flags().BoolVar(&mailSearchJSON, "json", false, "Output as JSON")
|
mailSearchCmd.Flags().BoolVar(&mailSearchJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
|
// Announces flags
|
||||||
|
mailAnnouncesCmd.Flags().BoolVar(&mailAnnouncesJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
mailCmd.AddCommand(mailSendCmd)
|
mailCmd.AddCommand(mailSendCmd)
|
||||||
mailCmd.AddCommand(mailInboxCmd)
|
mailCmd.AddCommand(mailInboxCmd)
|
||||||
@@ -414,6 +452,7 @@ func init() {
|
|||||||
mailCmd.AddCommand(mailReleaseCmd)
|
mailCmd.AddCommand(mailReleaseCmd)
|
||||||
mailCmd.AddCommand(mailClearCmd)
|
mailCmd.AddCommand(mailClearCmd)
|
||||||
mailCmd.AddCommand(mailSearchCmd)
|
mailCmd.AddCommand(mailSearchCmd)
|
||||||
|
mailCmd.AddCommand(mailAnnouncesCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(mailCmd)
|
rootCmd.AddCommand(mailCmd)
|
||||||
}
|
}
|
||||||
@@ -1702,3 +1741,233 @@ func runMailSearch(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
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"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMatchWorkerPattern(t *testing.T) {
|
func TestMatchWorkerPattern(t *testing.T) {
|
||||||
@@ -267,3 +269,115 @@ func validateRelease(msgInfo *messageInfo, caller string) error {
|
|||||||
|
|
||||||
return nil
|
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