feat(mail): Add gt mail release command for releasing claimed queue messages
Implements the release subcommand to allow workers to release claimed messages back to their queues. The command: - Verifies the caller owns the claimed message - Validates the message has a queue label - Returns the message to open status with queue assignee Includes TestMailReleaseValidation unit tests. (gt-guyt5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
261defa3b4
commit
7552be25e5
@@ -268,6 +268,32 @@ Examples:
|
||||
RunE: runMailClaim,
|
||||
}
|
||||
|
||||
var mailReleaseCmd = &cobra.Command{
|
||||
Use: "release <message-id>",
|
||||
Short: "Release a claimed queue message",
|
||||
Long: `Release a previously claimed message back to its queue.
|
||||
|
||||
SYNTAX:
|
||||
gt mail release <message-id>
|
||||
|
||||
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
|
||||
|
||||
ERROR CASES:
|
||||
- Message not found
|
||||
- Message not claimed (still assigned to queue)
|
||||
- Caller did not claim this message
|
||||
|
||||
Examples:
|
||||
gt mail release hq-abc123 # Release a claimed message`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailRelease,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Send flags
|
||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
||||
@@ -318,6 +344,7 @@ func init() {
|
||||
mailCmd.AddCommand(mailThreadCmd)
|
||||
mailCmd.AddCommand(mailReplyCmd)
|
||||
mailCmd.AddCommand(mailClaimCmd)
|
||||
mailCmd.AddCommand(mailReleaseCmd)
|
||||
|
||||
rootCmd.AddCommand(mailCmd)
|
||||
}
|
||||
@@ -1325,3 +1352,146 @@ func claimMessage(townRoot, messageID, claimant string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMailRelease releases a claimed queue message back to its queue.
|
||||
func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||
messageID := args[0]
|
||||
|
||||
// Find workspace
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Get caller identity
|
||||
caller := detectSender()
|
||||
|
||||
// Get message details to verify ownership and find queue
|
||||
msgInfo, err := getMessageInfo(townRoot, messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting message: %w", err)
|
||||
}
|
||||
|
||||
// Verify message exists and is a queue message
|
||||
if msgInfo.QueueName == "" {
|
||||
return fmt.Errorf("message %s is not a queue message (no queue label)", messageID)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("releasing message: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Released message back to queue %s\n", style.Bold.Render("✓"), msgInfo.QueueName)
|
||||
fmt.Printf(" ID: %s\n", messageID)
|
||||
fmt.Printf(" Subject: %s\n", msgInfo.Title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// messageInfo holds details about a queue message.
|
||||
type messageInfo struct {
|
||||
ID string
|
||||
Title string
|
||||
Assignee string
|
||||
QueueName string
|
||||
Status string
|
||||
}
|
||||
|
||||
// getMessageInfo retrieves information about a message.
|
||||
func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
|
||||
args := []string{"show", messageID, "--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 strings.Contains(errMsg, "not found") {
|
||||
return nil, fmt.Errorf("message not found: %s", messageID)
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output - bd show --json returns an array
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Assignee string `json:"assignee"`
|
||||
Labels []string `json:"labels"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||
return nil, fmt.Errorf("parsing message: %w", err)
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
return nil, fmt.Errorf("message not found: %s", messageID)
|
||||
}
|
||||
|
||||
issue := issues[0]
|
||||
info := &messageInfo{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Assignee: issue.Assignee,
|
||||
Status: issue.Status,
|
||||
}
|
||||
|
||||
// Extract queue name from labels (format: "queue:<name>")
|
||||
for _, label := range issue.Labels {
|
||||
if strings.HasPrefix(label, "queue:") {
|
||||
info.QueueName = strings.TrimPrefix(label, "queue:")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchWorkerPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -158,3 +162,108 @@ func TestIsEligibleWorker(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailReleaseValidation tests the validation logic for the release command.
|
||||
// This tests that release correctly identifies:
|
||||
// - Messages not claimed (still in queue)
|
||||
// - Messages claimed by a different worker
|
||||
// - Messages without queue labels (non-queue messages)
|
||||
func TestMailReleaseValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgInfo *messageInfo
|
||||
caller string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "caller matches assignee - valid release",
|
||||
msgInfo: &messageInfo{
|
||||
ID: "hq-test1",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "message still in queue - not claimed",
|
||||
msgInfo: &messageInfo{
|
||||
ID: "hq-test2",
|
||||
Title: "Test Message",
|
||||
Assignee: "queue:work/gastown",
|
||||
QueueName: "work/gastown",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: true,
|
||||
errContains: "not claimed",
|
||||
},
|
||||
{
|
||||
name: "claimed by different worker",
|
||||
msgInfo: &messageInfo{
|
||||
ID: "hq-test3",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/other",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: true,
|
||||
errContains: "was claimed by",
|
||||
},
|
||||
{
|
||||
name: "not a queue message",
|
||||
msgInfo: &messageInfo{
|
||||
ID: "hq-test4",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
QueueName: "", // No queue label
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: true,
|
||||
errContains: "not a queue message",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRelease(tt.msgInfo, tt.caller)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user