From c1469018bed7f046d8bab35ceaefdfa543e35ced Mon Sep 17 00:00:00 2001 From: gastown/polecats/capable Date: Thu, 1 Jan 2026 17:34:06 -0800 Subject: [PATCH] feat(mail): Add gt mail claim command for queue message claiming (gt-t1kso) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `gt mail claim ` command that: - Lists unclaimed messages in a work queue - Picks the oldest unclaimed message - Verifies caller eligibility against workers patterns - Claims the message by updating assignee and status - Prints claimed message details Includes tests for worker pattern matching. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mail.go | 259 ++++++++++++++++++++++++++++++++++++++ internal/cmd/mail_test.go | 160 +++++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 internal/cmd/mail_test.go diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 773037e0..e0dbdb4f 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -1,15 +1,20 @@ package cmd import ( + "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" + "os/exec" "path/filepath" + "sort" "strings" + "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/style" @@ -238,6 +243,31 @@ Examples: RunE: runMailReply, } +var mailClaimCmd = &cobra.Command{ + Use: "claim ", + Short: "Claim a message from a queue", + Long: `Claim the oldest unclaimed message from a work queue. + +SYNTAX: + gt mail claim + +BEHAVIOR: +1. List unclaimed messages in the queue +2. Pick the oldest unclaimed message +3. Set assignee to caller identity +4. Set status to in_progress +5. Print claimed message details + +ELIGIBILITY: +The caller must match a pattern in the queue's workers list +(defined in ~/gt/config/messaging.json). + +Examples: + gt mail claim work/gastown # Claim from gastown work queue`, + Args: cobra.ExactArgs(1), + RunE: runMailClaim, +} + func init() { // Send flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") @@ -287,6 +317,7 @@ func init() { mailCmd.AddCommand(mailCheckCmd) mailCmd.AddCommand(mailThreadCmd) mailCmd.AddCommand(mailReplyCmd) + mailCmd.AddCommand(mailClaimCmd) rootCmd.AddCommand(mailCmd) } @@ -1066,3 +1097,231 @@ func generateThreadID() string { _, _ = rand.Read(b) // crypto/rand.Read only fails on broken system return "thread-" + hex.EncodeToString(b) } + +// runMailClaim claims the oldest unclaimed message from a work queue. +func runMailClaim(cmd *cobra.Command, args []string) error { + queueName := args[0] + + // Find workspace + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Load queue config from messaging.json + configPath := config.MessagingConfigPath(townRoot) + cfg, err := config.LoadMessagingConfig(configPath) + if err != nil { + return fmt.Errorf("loading messaging config: %w", err) + } + + queueCfg, ok := cfg.Queues[queueName] + if !ok { + return fmt.Errorf("unknown queue: %s", queueName) + } + + // Get caller identity + caller := detectSender() + + // Check if caller is eligible (matches any pattern in workers list) + if !isEligibleWorker(caller, queueCfg.Workers) { + return fmt.Errorf("not eligible to claim from queue %s (caller: %s, workers: %v)", + queueName, caller, queueCfg.Workers) + } + + // List unclaimed messages in the queue + // Queue messages have assignee=queue: and status=open + queueAssignee := "queue:" + queueName + messages, err := listQueueMessages(townRoot, queueAssignee) + if err != nil { + return fmt.Errorf("listing queue messages: %w", err) + } + + if len(messages) == 0 { + fmt.Printf("%s No messages to claim in queue %s\n", style.Dim.Render("○"), queueName) + return nil + } + + // Pick the oldest unclaimed message (first in list, sorted by created) + oldest := messages[0] + + // Claim the message: set assignee to caller and status to in_progress + if err := claimMessage(townRoot, oldest.ID, caller); err != nil { + return fmt.Errorf("claiming message: %w", err) + } + + // Print claimed message details + fmt.Printf("%s Claimed message from queue %s\n", style.Bold.Render("✓"), queueName) + fmt.Printf(" ID: %s\n", oldest.ID) + fmt.Printf(" Subject: %s\n", oldest.Title) + if oldest.Description != "" { + // Show first line of description + lines := strings.SplitN(oldest.Description, "\n", 2) + preview := lines[0] + if len(preview) > 80 { + preview = preview[:77] + "..." + } + fmt.Printf(" Preview: %s\n", style.Dim.Render(preview)) + } + fmt.Printf(" From: %s\n", oldest.From) + fmt.Printf(" Created: %s\n", oldest.Created.Format("2006-01-02 15:04")) + + return nil +} + +// queueMessage represents a message in a queue. +type queueMessage struct { + ID string + Title string + Description string + From string + Created time.Time + Priority int +} + +// isEligibleWorker checks if the caller matches any pattern in the workers list. +// Patterns support wildcards: "gastown/polecats/*" matches "gastown/polecats/capable". +func isEligibleWorker(caller string, patterns []string) bool { + for _, pattern := range patterns { + if matchWorkerPattern(pattern, caller) { + return true + } + } + return false +} + +// matchWorkerPattern checks if caller matches the pattern. +// Supports simple wildcards: * matches a single path segment (no slashes). +func matchWorkerPattern(pattern, caller string) bool { + // Handle exact match + if pattern == caller { + return true + } + + // Handle wildcard patterns + if strings.Contains(pattern, "*") { + // Convert to simple glob matching + // "gastown/polecats/*" should match "gastown/polecats/capable" + // but NOT "gastown/polecats/sub/capable" + parts := strings.Split(pattern, "*") + if len(parts) == 2 { + prefix := parts[0] + suffix := parts[1] + if strings.HasPrefix(caller, prefix) && strings.HasSuffix(caller, suffix) { + // Check that the middle part doesn't contain path separators + middle := caller[len(prefix) : len(caller)-len(suffix)] + if !strings.Contains(middle, "/") { + return true + } + } + } + } + + return false +} + +// listQueueMessages lists unclaimed messages in a queue. +func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) { + // Use bd list to find messages with assignee=queue: and status=open + beadsDir := filepath.Join(townRoot, ".beads") + + args := []string{"list", + "--assignee", queueAssignee, + "--status", "open", + "--type", "message", + "--sort", "created", + "--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"` + } + + if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { + // If no messages, bd might output empty or error + if strings.TrimSpace(stdout.String()) == "" || strings.TrimSpace(stdout.String()) == "[]" { + return nil, nil + } + return nil, fmt.Errorf("parsing bd output: %w", err) + } + + // Convert to queueMessage, extracting 'from' from labels + var messages []queueMessage + for _, issue := range issues { + msg := queueMessage{ + 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) + } + + // Sort by created time (oldest first) + sort.Slice(messages, func(i, j int) bool { + return messages[i].Created.Before(messages[j].Created) + }) + + return messages, nil +} + +// claimMessage claims a message by setting assignee and status. +func claimMessage(townRoot, messageID, claimant string) error { + beadsDir := filepath.Join(townRoot, ".beads") + + args := []string{"update", messageID, + "--assignee", claimant, + "--status", "in_progress", + } + + cmd := exec.Command("bd", args...) + cmd.Env = append(os.Environ(), + "BEADS_DIR="+beadsDir, + "BD_ACTOR="+claimant, + ) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return fmt.Errorf("%s", errMsg) + } + return err + } + + return nil +} diff --git a/internal/cmd/mail_test.go b/internal/cmd/mail_test.go new file mode 100644 index 00000000..4c44633c --- /dev/null +++ b/internal/cmd/mail_test.go @@ -0,0 +1,160 @@ +package cmd + +import "testing" + +func TestMatchWorkerPattern(t *testing.T) { + tests := []struct { + name string + pattern string + caller string + want bool + }{ + // Exact matches + { + name: "exact match", + pattern: "gastown/polecats/capable", + caller: "gastown/polecats/capable", + want: true, + }, + { + name: "exact match with different name", + pattern: "gastown/polecats/toast", + caller: "gastown/polecats/capable", + want: false, + }, + + // Wildcard at end + { + name: "wildcard matches polecat", + pattern: "gastown/polecats/*", + caller: "gastown/polecats/capable", + want: true, + }, + { + name: "wildcard matches different polecat", + pattern: "gastown/polecats/*", + caller: "gastown/polecats/toast", + want: true, + }, + { + name: "wildcard doesn't match wrong rig", + pattern: "gastown/polecats/*", + caller: "beads/polecats/capable", + want: false, + }, + { + name: "wildcard doesn't match nested path", + pattern: "gastown/polecats/*", + caller: "gastown/polecats/sub/capable", + want: false, + }, + + // Crew patterns + { + name: "crew wildcard matches", + pattern: "gastown/crew/*", + caller: "gastown/crew/max", + want: true, + }, + { + name: "crew wildcard doesn't match polecats", + pattern: "gastown/crew/*", + caller: "gastown/polecats/capable", + want: false, + }, + + // Different rigs + { + name: "different rig wildcard", + pattern: "beads/polecats/*", + caller: "beads/polecats/capable", + want: true, + }, + + // Edge cases + { + name: "empty pattern", + pattern: "", + caller: "gastown/polecats/capable", + want: false, + }, + { + name: "empty caller", + pattern: "gastown/polecats/*", + caller: "", + want: false, + }, + { + name: "pattern is just wildcard", + pattern: "*", + caller: "anything", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := matchWorkerPattern(tt.pattern, tt.caller) + if got != tt.want { + t.Errorf("matchWorkerPattern(%q, %q) = %v, want %v", + tt.pattern, tt.caller, got, tt.want) + } + }) + } +} + +func TestIsEligibleWorker(t *testing.T) { + tests := []struct { + name string + caller string + patterns []string + want bool + }{ + { + name: "matches first pattern", + caller: "gastown/polecats/capable", + patterns: []string{"gastown/polecats/*", "gastown/crew/*"}, + want: true, + }, + { + name: "matches second pattern", + caller: "gastown/crew/max", + patterns: []string{"gastown/polecats/*", "gastown/crew/*"}, + want: true, + }, + { + name: "matches none", + caller: "beads/polecats/capable", + patterns: []string{"gastown/polecats/*", "gastown/crew/*"}, + want: false, + }, + { + name: "empty patterns list", + caller: "gastown/polecats/capable", + patterns: []string{}, + want: false, + }, + { + name: "nil patterns", + caller: "gastown/polecats/capable", + patterns: nil, + want: false, + }, + { + name: "exact match in list", + caller: "mayor/", + patterns: []string{"mayor/", "gastown/witness"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isEligibleWorker(tt.caller, tt.patterns) + if got != tt.want { + t.Errorf("isEligibleWorker(%q, %v) = %v, want %v", + tt.caller, tt.patterns, got, tt.want) + } + }) + } +}