feat(mail): implement beads-native gt mail claim command
Implement claiming for queue messages using beads-native approach: - Add claim_pattern field to QueueFields for eligibility checking - Add MatchClaimPattern function for pattern matching (wildcards supported) - Add FindEligibleQueues to find all queues an agent can claim from - Rewrite runMailClaim to use beads-native queue lookup - Support optional queue argument (claim from any eligible if not specified) - Use claimed-by/claimed-at labels instead of changing assignee - Update runMailRelease to work with new claiming approach - Add comprehensive tests for pattern matching and validation Queue messages are now claimed via labels: - claimed-by: <agent-identity> - claimed-at: <RFC3339 timestamp> Messages with queue:<name> label but no claimed-by are unclaimed. Closes gt-xfqh1e.11 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
012d50b2b2
commit
2ffc8e8712
@@ -277,27 +277,27 @@ Examples:
|
||||
}
|
||||
|
||||
var mailClaimCmd = &cobra.Command{
|
||||
Use: "claim <queue-name>",
|
||||
Use: "claim [queue-name]",
|
||||
Short: "Claim a message from a queue",
|
||||
Long: `Claim the oldest unclaimed message from a work queue.
|
||||
|
||||
SYNTAX:
|
||||
gt mail claim <queue-name>
|
||||
gt mail claim [queue-name]
|
||||
|
||||
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
|
||||
1. If queue specified, claim from that queue
|
||||
2. If no queue specified, claim from any eligible queue
|
||||
3. Add claimed-by and claimed-at labels to the message
|
||||
4. Print claimed message details
|
||||
|
||||
ELIGIBILITY:
|
||||
The caller must match a pattern in the queue's workers list
|
||||
(defined in ~/gt/config/messaging.json).
|
||||
The caller must match the queue's claim_pattern (stored in the queue bead).
|
||||
Pattern examples: "*" (anyone), "gastown/polecats/*" (specific rig crew).
|
||||
|
||||
Examples:
|
||||
gt mail claim work/gastown # Claim from gastown work queue`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
gt mail claim work-requests # Claim from specific queue
|
||||
gt mail claim # Claim from any eligible queue`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runMailClaim,
|
||||
}
|
||||
|
||||
@@ -311,14 +311,14 @@ SYNTAX:
|
||||
|
||||
BEHAVIOR:
|
||||
1. Find the message by ID
|
||||
2. Verify caller is the one who claimed it (assignee matches)
|
||||
3. Set assignee back to queue:<name> (from message labels)
|
||||
4. Set status back to open
|
||||
5. Message returns to queue for others to claim
|
||||
2. Verify caller is the one who claimed it (claimed-by label matches)
|
||||
3. Remove claimed-by and claimed-at labels
|
||||
4. Message returns to queue for others to claim
|
||||
|
||||
ERROR CASES:
|
||||
- Message not found
|
||||
- Message not claimed (still assigned to queue)
|
||||
- Message is not a queue message
|
||||
- Message not claimed
|
||||
- Caller did not claim this message
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -6,52 +6,86 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// runMailClaim claims the oldest unclaimed message from a work queue.
|
||||
// If a queue name is provided, claims from that specific queue.
|
||||
// If no queue name is provided, claims from any queue the caller is eligible for.
|
||||
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()
|
||||
beadsDir := beads.ResolveBeadsDir(townRoot)
|
||||
bd := beads.NewWithBeadsDir(townRoot, beadsDir)
|
||||
|
||||
// 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)
|
||||
var queueName string
|
||||
var queueFields *beads.QueueFields
|
||||
|
||||
if len(args) > 0 {
|
||||
// Specific queue requested
|
||||
queueName = args[0]
|
||||
|
||||
// Look up the queue bead
|
||||
queueID := beads.QueueBeadID(queueName, true) // Try town-level first
|
||||
issue, fields, err := bd.GetQueueBead(queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up queue: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
// Try rig-level
|
||||
queueID = beads.QueueBeadID(queueName, false)
|
||||
issue, fields, err = bd.GetQueueBead(queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up queue: %w", err)
|
||||
}
|
||||
if issue == nil {
|
||||
return fmt.Errorf("unknown queue: %s", queueName)
|
||||
}
|
||||
}
|
||||
queueFields = fields
|
||||
|
||||
// Check if caller is eligible
|
||||
if !beads.MatchClaimPattern(queueFields.ClaimPattern, caller) {
|
||||
return fmt.Errorf("not eligible to claim from queue %s (caller: %s, pattern: %s)",
|
||||
queueName, caller, queueFields.ClaimPattern)
|
||||
}
|
||||
} else {
|
||||
// No queue specified - find any queue the caller can claim from
|
||||
eligibleIssues, eligibleFields, err := bd.FindEligibleQueues(caller)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding eligible queues: %w", err)
|
||||
}
|
||||
if len(eligibleIssues) == 0 {
|
||||
fmt.Printf("%s No queues available for claiming (caller: %s)\n",
|
||||
style.Dim.Render("○"), caller)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the first eligible queue
|
||||
queueFields = eligibleFields[0]
|
||||
queueName = queueFields.Name
|
||||
if queueName == "" {
|
||||
// Fallback to ID-based name
|
||||
queueName = eligibleIssues[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
// List unclaimed messages in the queue
|
||||
// Queue messages have assignee=queue:<name> and status=open
|
||||
queueAssignee := "queue:" + queueName
|
||||
messages, err := listQueueMessages(townRoot, queueAssignee)
|
||||
// Queue messages have queue:<name> label and no claimed-by label
|
||||
messages, err := listUnclaimedQueueMessages(beadsDir, queueName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing queue messages: %w", err)
|
||||
}
|
||||
@@ -64,8 +98,8 @@ func runMailClaim(cmd *cobra.Command, args []string) error {
|
||||
// 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 {
|
||||
// Claim the message: add claimed-by and claimed-at labels
|
||||
if err := claimQueueMessage(beadsDir, oldest.ID, caller); err != nil {
|
||||
return fmt.Errorf("claiming message: %w", err)
|
||||
}
|
||||
|
||||
@@ -96,60 +130,18 @@ type queueMessage struct {
|
||||
From string
|
||||
Created time.Time
|
||||
Priority int
|
||||
ClaimedBy string
|
||||
ClaimedAt *time.Time
|
||||
}
|
||||
|
||||
// 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:<name> and status=open
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// listUnclaimedQueueMessages lists unclaimed messages in a queue.
|
||||
// Unclaimed messages have queue:<name> label but no claimed-by label.
|
||||
func listUnclaimedQueueMessages(beadsDir, queueName string) ([]queueMessage, error) {
|
||||
// Use bd list to find messages with queue:<name> label and status=open
|
||||
args := []string{"list",
|
||||
"--assignee", queueAssignee,
|
||||
"--label", "queue:" + queueName,
|
||||
"--status", "open",
|
||||
"--type", "message",
|
||||
"--sort", "created",
|
||||
"--limit", "0", // No limit
|
||||
"--json",
|
||||
}
|
||||
|
||||
@@ -186,7 +178,7 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||
}
|
||||
|
||||
// Convert to queueMessage, extracting 'from' from labels
|
||||
// Convert to queueMessage, filtering out already claimed messages
|
||||
var messages []queueMessage
|
||||
for _, issue := range issues {
|
||||
msg := queueMessage{
|
||||
@@ -197,18 +189,27 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||
Priority: issue.Priority,
|
||||
}
|
||||
|
||||
// Extract 'from' from labels (format: "from:address")
|
||||
// Extract labels
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "from:") {
|
||||
msg.From = strings.TrimPrefix(label, "from:")
|
||||
break
|
||||
} else if strings.HasPrefix(label, "claimed-by:") {
|
||||
msg.ClaimedBy = strings.TrimPrefix(label, "claimed-by:")
|
||||
} else if strings.HasPrefix(label, "claimed-at:") {
|
||||
ts := strings.TrimPrefix(label, "claimed-at:")
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
msg.ClaimedAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, msg)
|
||||
// Only include unclaimed messages
|
||||
if msg.ClaimedBy == "" {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by created time (oldest first)
|
||||
// Sort by created time (oldest first) for FIFO ordering
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Created.Before(messages[j].Created)
|
||||
})
|
||||
@@ -216,13 +217,13 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// claimMessage claims a message by setting assignee and status.
|
||||
func claimMessage(townRoot, messageID, claimant string) error {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
// claimQueueMessage claims a message by adding claimed-by and claimed-at labels.
|
||||
func claimQueueMessage(beadsDir, messageID, claimant string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
args := []string{"update", messageID,
|
||||
"--assignee", claimant,
|
||||
"--status", "in_progress",
|
||||
args := []string{"label", "add", messageID,
|
||||
"claimed-by:" + claimant,
|
||||
"claimed-at:" + now,
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
@@ -255,11 +256,13 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
beadsDir := beads.ResolveBeadsDir(townRoot)
|
||||
|
||||
// Get caller identity
|
||||
caller := detectSender()
|
||||
|
||||
// Get message details to verify ownership and find queue
|
||||
msgInfo, err := getMessageInfo(townRoot, messageID)
|
||||
msgInfo, err := getQueueMessageInfo(beadsDir, messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting message: %w", err)
|
||||
}
|
||||
@@ -270,16 +273,15 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Verify caller is the one who claimed it
|
||||
if msgInfo.Assignee != caller {
|
||||
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
|
||||
return fmt.Errorf("message %s is not claimed (still in queue)", messageID)
|
||||
}
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.Assignee, caller)
|
||||
if msgInfo.ClaimedBy == "" {
|
||||
return fmt.Errorf("message %s is not claimed", messageID)
|
||||
}
|
||||
if msgInfo.ClaimedBy != caller {
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.ClaimedBy, caller)
|
||||
}
|
||||
|
||||
// Release the message: set assignee back to queue and status to open
|
||||
queueAssignee := "queue:" + msgInfo.QueueName
|
||||
if err := releaseMessage(townRoot, messageID, queueAssignee, caller); err != nil {
|
||||
// Release the message: remove claimed-by and claimed-at labels
|
||||
if err := releaseQueueMessage(beadsDir, messageID, caller); err != nil {
|
||||
return fmt.Errorf("releasing message: %w", err)
|
||||
}
|
||||
|
||||
@@ -290,19 +292,18 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// messageInfo holds details about a queue message.
|
||||
type messageInfo struct {
|
||||
// queueMessageInfo holds details about a queue message.
|
||||
type queueMessageInfo struct {
|
||||
ID string
|
||||
Title string
|
||||
Assignee string
|
||||
QueueName string
|
||||
ClaimedBy string
|
||||
ClaimedAt *time.Time
|
||||
Status string
|
||||
}
|
||||
|
||||
// getMessageInfo retrieves information about a message.
|
||||
func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
// getQueueMessageInfo retrieves information about a queue message.
|
||||
func getQueueMessageInfo(beadsDir, messageID string) (*queueMessageInfo, error) {
|
||||
args := []string{"show", messageID, "--json"}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
@@ -327,7 +328,6 @@ func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Assignee string `json:"assignee"`
|
||||
Labels []string `json:"labels"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -341,48 +341,76 @@ func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
}
|
||||
|
||||
issue := issues[0]
|
||||
info := &messageInfo{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Assignee: issue.Assignee,
|
||||
Status: issue.Status,
|
||||
info := &queueMessageInfo{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Status: issue.Status,
|
||||
}
|
||||
|
||||
// Extract queue name from labels (format: "queue:<name>")
|
||||
// Extract fields from labels
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "queue:") {
|
||||
info.QueueName = strings.TrimPrefix(label, "queue:")
|
||||
break
|
||||
} else if strings.HasPrefix(label, "claimed-by:") {
|
||||
info.ClaimedBy = strings.TrimPrefix(label, "claimed-by:")
|
||||
} else if strings.HasPrefix(label, "claimed-at:") {
|
||||
ts := strings.TrimPrefix(label, "claimed-at:")
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
info.ClaimedAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// releaseMessage releases a claimed message back to its queue.
|
||||
func releaseMessage(townRoot, messageID, queueAssignee, actor string) error {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
args := []string{"update", messageID,
|
||||
"--assignee", queueAssignee,
|
||||
"--status", "open",
|
||||
// releaseQueueMessage releases a claimed message by removing claim labels.
|
||||
func releaseQueueMessage(beadsDir, messageID, actor string) error {
|
||||
// Get current message info to find the exact claim labels
|
||||
info, err := getQueueMessageInfo(beadsDir, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"BEADS_DIR="+beadsDir,
|
||||
"BD_ACTOR="+actor,
|
||||
)
|
||||
// Remove claimed-by label
|
||||
if info.ClaimedBy != "" {
|
||||
args := []string{"label", "remove", messageID, "claimed-by:" + info.ClaimedBy}
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"BEADS_DIR="+beadsDir,
|
||||
"BD_ACTOR="+actor,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
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)
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" && !strings.Contains(errMsg, "does not have label") {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove claimed-at label if present
|
||||
if info.ClaimedAt != nil {
|
||||
claimedAtStr := info.ClaimedAt.Format(time.RFC3339)
|
||||
args := []string{"label", "remove", messageID, "claimed-at:" + claimedAtStr}
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"BEADS_DIR="+beadsDir,
|
||||
"BD_ACTOR="+actor,
|
||||
)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" && !strings.Contains(errMsg, "does not have label") {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
func TestMatchWorkerPattern(t *testing.T) {
|
||||
// TestClaimPatternMatching tests claim pattern matching via the beads package.
|
||||
// This verifies that the pattern matching used for queue eligibility works correctly.
|
||||
func TestClaimPatternMatching(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
@@ -55,43 +58,9 @@ func TestMatchWorkerPattern(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Crew patterns
|
||||
// Universal wildcard
|
||||
{
|
||||
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",
|
||||
name: "universal wildcard matches anything",
|
||||
pattern: "*",
|
||||
caller: "anything",
|
||||
want: true,
|
||||
@@ -100,103 +69,47 @@ func TestMatchWorkerPattern(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchWorkerPattern(tt.pattern, tt.caller)
|
||||
got := beads.MatchClaimPattern(tt.pattern, tt.caller)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchWorkerPattern(%q, %q) = %v, want %v",
|
||||
t.Errorf("MatchClaimPattern(%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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailReleaseValidation tests the validation logic for the release command.
|
||||
// TestQueueMessageReleaseValidation tests the validation logic for the release command.
|
||||
// This tests that release correctly identifies:
|
||||
// - Messages not claimed (still in queue)
|
||||
// - Messages not claimed (no claimed-by label)
|
||||
// - Messages claimed by a different worker
|
||||
// - Messages without queue labels (non-queue messages)
|
||||
func TestMailReleaseValidation(t *testing.T) {
|
||||
func TestQueueMessageReleaseValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgInfo *messageInfo
|
||||
msgInfo *queueMessageInfo
|
||||
caller string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "caller matches assignee - valid release",
|
||||
msgInfo: &messageInfo{
|
||||
name: "caller matches claimed-by - valid release",
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test1",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
ClaimedBy: "gastown/polecats/nux",
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "message still in queue - not claimed",
|
||||
msgInfo: &messageInfo{
|
||||
name: "message not claimed",
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test2",
|
||||
Title: "Test Message",
|
||||
Assignee: "queue:work/gastown",
|
||||
QueueName: "work/gastown",
|
||||
ClaimedBy: "", // Not claimed
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
@@ -205,12 +118,12 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "claimed by different worker",
|
||||
msgInfo: &messageInfo{
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test3",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/other",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
ClaimedBy: "gastown/polecats/other",
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: true,
|
||||
@@ -218,10 +131,10 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "not a queue message",
|
||||
msgInfo: &messageInfo{
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test4",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
ClaimedBy: "gastown/polecats/nux",
|
||||
QueueName: "", // No queue label
|
||||
Status: "open",
|
||||
},
|
||||
@@ -233,7 +146,7 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRelease(tt.msgInfo, tt.caller)
|
||||
err := validateQueueRelease(tt.msgInfo, tt.caller)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
@@ -251,20 +164,22 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateRelease checks if a message can be released by the caller.
|
||||
// This is extracted for testing; the actual release command uses this logic inline.
|
||||
func validateRelease(msgInfo *messageInfo, caller string) error {
|
||||
// validateQueueRelease checks if a queue message can be released by the caller.
|
||||
// This mirrors the validation logic in runMailRelease.
|
||||
func validateQueueRelease(msgInfo *queueMessageInfo, caller string) error {
|
||||
// Verify message is a queue message
|
||||
if msgInfo.QueueName == "" {
|
||||
return fmt.Errorf("message %s is not a queue message (no queue label)", msgInfo.ID)
|
||||
}
|
||||
|
||||
// Verify message is claimed
|
||||
if msgInfo.ClaimedBy == "" {
|
||||
return fmt.Errorf("message %s is not claimed", msgInfo.ID)
|
||||
}
|
||||
|
||||
// Verify caller is the one who claimed it
|
||||
if msgInfo.Assignee != caller {
|
||||
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
|
||||
return fmt.Errorf("message %s is not claimed (still in queue)", msgInfo.ID)
|
||||
}
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.Assignee, caller)
|
||||
if msgInfo.ClaimedBy != caller {
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.ClaimedBy, caller)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user