Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy, TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage) from internal/types/types.go. Beads now only has core work types built-in: - bug, feature, task, epic, chore All Gas Town types are now purely custom types with no special handling in beads. Use string literals like "gate" or "molecule" when needed, and configure types.custom in config.yaml for validation. Changes: - Remove Gas Town type constants from types.go - Remove mr/mol aliases from Normalize() - Update bd types command to only show core types - Replace all constant usages with string literals throughout codebase - Update tests to use string literals This decouples beads from Gas Town, making it a generic issue tracker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
415 lines
12 KiB
Go
415 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestThreadTraversal tests the findRepliesTo() and findReplies() functions
|
|
// that were added in Decision 004 Phase 4 to support message thread navigation.
|
|
func TestThreadTraversal(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
|
ctx := context.Background()
|
|
|
|
// Create a 3-message thread chain: original → reply1 → reply2
|
|
now := time.Now()
|
|
|
|
original := &types.Issue{
|
|
Title: "Original Message",
|
|
Description: "This is the original message",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "worker",
|
|
Sender: "manager",
|
|
Ephemeral: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := testStore.CreateIssue(ctx, original, "test"); err != nil {
|
|
t.Fatalf("Failed to create original message: %v", err)
|
|
}
|
|
|
|
reply1 := &types.Issue{
|
|
Title: "Re: Original Message",
|
|
Description: "This is reply 1",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "manager",
|
|
Sender: "worker",
|
|
Ephemeral: true,
|
|
CreatedAt: now.Add(time.Minute),
|
|
UpdatedAt: now.Add(time.Minute),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, reply1, "test"); err != nil {
|
|
t.Fatalf("Failed to create reply1: %v", err)
|
|
}
|
|
|
|
reply2 := &types.Issue{
|
|
Title: "Re: Re: Original Message",
|
|
Description: "This is reply 2",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "worker",
|
|
Sender: "manager",
|
|
Ephemeral: true,
|
|
CreatedAt: now.Add(2 * time.Minute),
|
|
UpdatedAt: now.Add(2 * time.Minute),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, reply2, "test"); err != nil {
|
|
t.Fatalf("Failed to create reply2: %v", err)
|
|
}
|
|
|
|
// Add replies-to dependencies to form the thread chain
|
|
// reply1 replies to original
|
|
dep1 := &types.Dependency{
|
|
IssueID: reply1.ID,
|
|
DependsOnID: original.ID,
|
|
Type: types.DepRepliesTo,
|
|
CreatedAt: now.Add(time.Minute),
|
|
}
|
|
if err := testStore.AddDependency(ctx, dep1, "test"); err != nil {
|
|
t.Fatalf("Failed to add reply1 -> original dependency: %v", err)
|
|
}
|
|
|
|
// reply2 replies to reply1
|
|
dep2 := &types.Dependency{
|
|
IssueID: reply2.ID,
|
|
DependsOnID: reply1.ID,
|
|
Type: types.DepRepliesTo,
|
|
CreatedAt: now.Add(2 * time.Minute),
|
|
}
|
|
if err := testStore.AddDependency(ctx, dep2, "test"); err != nil {
|
|
t.Fatalf("Failed to add reply2 -> reply1 dependency: %v", err)
|
|
}
|
|
|
|
t.Run("findRepliesTo walks UP the thread", func(t *testing.T) {
|
|
// From reply2, should find reply1
|
|
parent := findRepliesTo(ctx, reply2.ID, nil, testStore)
|
|
if parent != reply1.ID {
|
|
t.Errorf("findRepliesTo(reply2) = %q, want %q", parent, reply1.ID)
|
|
}
|
|
|
|
// From reply1, should find original
|
|
parent = findRepliesTo(ctx, reply1.ID, nil, testStore)
|
|
if parent != original.ID {
|
|
t.Errorf("findRepliesTo(reply1) = %q, want %q", parent, original.ID)
|
|
}
|
|
|
|
// From original, should return empty (no parent)
|
|
parent = findRepliesTo(ctx, original.ID, nil, testStore)
|
|
if parent != "" {
|
|
t.Errorf("findRepliesTo(original) = %q, want empty string", parent)
|
|
}
|
|
})
|
|
|
|
t.Run("findReplies walks DOWN the thread", func(t *testing.T) {
|
|
// From original, should find reply1
|
|
replies := findReplies(ctx, original.ID, nil, testStore)
|
|
if len(replies) != 1 {
|
|
t.Fatalf("findReplies(original) returned %d replies, want 1", len(replies))
|
|
}
|
|
if replies[0].ID != reply1.ID {
|
|
t.Errorf("findReplies(original)[0].ID = %q, want %q", replies[0].ID, reply1.ID)
|
|
}
|
|
|
|
// From reply1, should find reply2
|
|
replies = findReplies(ctx, reply1.ID, nil, testStore)
|
|
if len(replies) != 1 {
|
|
t.Fatalf("findReplies(reply1) returned %d replies, want 1", len(replies))
|
|
}
|
|
if replies[0].ID != reply2.ID {
|
|
t.Errorf("findReplies(reply1)[0].ID = %q, want %q", replies[0].ID, reply2.ID)
|
|
}
|
|
|
|
// From reply2, should return empty (no children)
|
|
replies = findReplies(ctx, reply2.ID, nil, testStore)
|
|
if len(replies) != 0 {
|
|
t.Errorf("findReplies(reply2) returned %d replies, want 0", len(replies))
|
|
}
|
|
})
|
|
|
|
t.Run("thread root finding via repeated findRepliesTo", func(t *testing.T) {
|
|
// Starting from reply2, walk up via findRepliesTo() until reaching original
|
|
current := reply2.ID
|
|
var visited []string
|
|
visited = append(visited, current)
|
|
|
|
for {
|
|
parent := findRepliesTo(ctx, current, nil, testStore)
|
|
if parent == "" {
|
|
break
|
|
}
|
|
current = parent
|
|
visited = append(visited, current)
|
|
}
|
|
|
|
// Should have visited: reply2, reply1, original
|
|
if len(visited) != 3 {
|
|
t.Fatalf("Thread walk visited %d nodes, want 3: %v", len(visited), visited)
|
|
}
|
|
|
|
// Final current should be the original (root)
|
|
if current != original.ID {
|
|
t.Errorf("Thread root = %q, want %q", current, original.ID)
|
|
}
|
|
|
|
// Verify visited order: reply2 -> reply1 -> original
|
|
if visited[0] != reply2.ID {
|
|
t.Errorf("visited[0] = %q, want %q", visited[0], reply2.ID)
|
|
}
|
|
if visited[1] != reply1.ID {
|
|
t.Errorf("visited[1] = %q, want %q", visited[1], reply1.ID)
|
|
}
|
|
if visited[2] != original.ID {
|
|
t.Errorf("visited[2] = %q, want %q", visited[2], original.ID)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestThreadTraversalEmptyThread tests thread traversal with an isolated message
|
|
func TestThreadTraversalEmptyThread(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
|
ctx := context.Background()
|
|
|
|
// Create a single message with no thread
|
|
now := time.Now()
|
|
standalone := &types.Issue{
|
|
Title: "Standalone Message",
|
|
Description: "This message has no thread",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "user",
|
|
Sender: "sender",
|
|
Ephemeral: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := testStore.CreateIssue(ctx, standalone, "test"); err != nil {
|
|
t.Fatalf("Failed to create standalone message: %v", err)
|
|
}
|
|
|
|
// findRepliesTo should return empty
|
|
parent := findRepliesTo(ctx, standalone.ID, nil, testStore)
|
|
if parent != "" {
|
|
t.Errorf("findRepliesTo(standalone) = %q, want empty string", parent)
|
|
}
|
|
|
|
// findReplies should return empty slice
|
|
replies := findReplies(ctx, standalone.ID, nil, testStore)
|
|
if len(replies) != 0 {
|
|
t.Errorf("findReplies(standalone) returned %d replies, want 0", len(replies))
|
|
}
|
|
}
|
|
|
|
// TestThreadTraversalBranching tests a branching thread (one message with multiple replies)
|
|
func TestThreadTraversalBranching(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
|
|
// Create original message
|
|
original := &types.Issue{
|
|
Title: "Original Message",
|
|
Description: "This message will have multiple replies",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "user",
|
|
Sender: "sender",
|
|
Ephemeral: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := testStore.CreateIssue(ctx, original, "test"); err != nil {
|
|
t.Fatalf("Failed to create original message: %v", err)
|
|
}
|
|
|
|
// Create two replies to the original (branching)
|
|
replyA := &types.Issue{
|
|
Title: "Reply A",
|
|
Description: "First branch reply",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "sender",
|
|
Sender: "user",
|
|
Ephemeral: true,
|
|
CreatedAt: now.Add(time.Minute),
|
|
UpdatedAt: now.Add(time.Minute),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, replyA, "test"); err != nil {
|
|
t.Fatalf("Failed to create replyA: %v", err)
|
|
}
|
|
|
|
replyB := &types.Issue{
|
|
Title: "Reply B",
|
|
Description: "Second branch reply",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "sender",
|
|
Sender: "another-user",
|
|
Ephemeral: true,
|
|
CreatedAt: now.Add(2 * time.Minute),
|
|
UpdatedAt: now.Add(2 * time.Minute),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, replyB, "test"); err != nil {
|
|
t.Fatalf("Failed to create replyB: %v", err)
|
|
}
|
|
|
|
// Both replies point to original
|
|
depA := &types.Dependency{
|
|
IssueID: replyA.ID,
|
|
DependsOnID: original.ID,
|
|
Type: types.DepRepliesTo,
|
|
CreatedAt: now.Add(time.Minute),
|
|
}
|
|
if err := testStore.AddDependency(ctx, depA, "test"); err != nil {
|
|
t.Fatalf("Failed to add replyA -> original dependency: %v", err)
|
|
}
|
|
|
|
depB := &types.Dependency{
|
|
IssueID: replyB.ID,
|
|
DependsOnID: original.ID,
|
|
Type: types.DepRepliesTo,
|
|
CreatedAt: now.Add(2 * time.Minute),
|
|
}
|
|
if err := testStore.AddDependency(ctx, depB, "test"); err != nil {
|
|
t.Fatalf("Failed to add replyB -> original dependency: %v", err)
|
|
}
|
|
|
|
t.Run("findRepliesTo from branches find original", func(t *testing.T) {
|
|
parentA := findRepliesTo(ctx, replyA.ID, nil, testStore)
|
|
if parentA != original.ID {
|
|
t.Errorf("findRepliesTo(replyA) = %q, want %q", parentA, original.ID)
|
|
}
|
|
|
|
parentB := findRepliesTo(ctx, replyB.ID, nil, testStore)
|
|
if parentB != original.ID {
|
|
t.Errorf("findRepliesTo(replyB) = %q, want %q", parentB, original.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("findReplies from original returns both branches", func(t *testing.T) {
|
|
replies := findReplies(ctx, original.ID, nil, testStore)
|
|
if len(replies) != 2 {
|
|
t.Fatalf("findReplies(original) returned %d replies, want 2", len(replies))
|
|
}
|
|
|
|
// Verify both replies are present (order may vary)
|
|
foundA := false
|
|
foundB := false
|
|
for _, r := range replies {
|
|
if r.ID == replyA.ID {
|
|
foundA = true
|
|
}
|
|
if r.ID == replyB.ID {
|
|
foundB = true
|
|
}
|
|
}
|
|
if !foundA {
|
|
t.Errorf("findReplies(original) missing replyA")
|
|
}
|
|
if !foundB {
|
|
t.Errorf("findReplies(original) missing replyB")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestThreadTraversalNonexistentIssue tests behavior with nonexistent issue IDs
|
|
func TestThreadTraversalNonexistentIssue(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
|
ctx := context.Background()
|
|
|
|
// findRepliesTo with nonexistent ID should return empty
|
|
parent := findRepliesTo(ctx, "nonexistent-id", nil, testStore)
|
|
if parent != "" {
|
|
t.Errorf("findRepliesTo(nonexistent) = %q, want empty string", parent)
|
|
}
|
|
|
|
// findReplies with nonexistent ID should return nil/empty
|
|
replies := findReplies(ctx, "nonexistent-id", nil, testStore)
|
|
if len(replies) != 0 {
|
|
t.Errorf("findReplies(nonexistent) returned %d replies, want 0", len(replies))
|
|
}
|
|
}
|
|
|
|
// TestThreadTraversalOnlyRepliesTo verifies that only replies-to dependencies are followed
|
|
func TestThreadTraversalOnlyRepliesTo(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
|
|
// Create three messages to test different dependency types
|
|
msg1 := &types.Issue{
|
|
Title: "Message 1",
|
|
Description: "First message (target of blocks dep)",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "user",
|
|
Sender: "sender",
|
|
Ephemeral: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := testStore.CreateIssue(ctx, msg1, "test"); err != nil {
|
|
t.Fatalf("Failed to create msg1: %v", err)
|
|
}
|
|
|
|
msg2 := &types.Issue{
|
|
Title: "Message 2",
|
|
Description: "Second message with blocks dependency to msg1",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Assignee: "user",
|
|
Sender: "sender",
|
|
Ephemeral: true,
|
|
CreatedAt: now.Add(time.Minute),
|
|
UpdatedAt: now.Add(time.Minute),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, msg2, "test"); err != nil {
|
|
t.Fatalf("Failed to create msg2: %v", err)
|
|
}
|
|
|
|
// Add a "blocks" dependency (NOT replies-to)
|
|
// msg2 depends on (blocks) msg1
|
|
blocksDep := &types.Dependency{
|
|
IssueID: msg2.ID,
|
|
DependsOnID: msg1.ID,
|
|
Type: types.DepBlocks,
|
|
CreatedAt: now.Add(time.Minute),
|
|
}
|
|
if err := testStore.AddDependency(ctx, blocksDep, "test"); err != nil {
|
|
t.Fatalf("Failed to add blocks dependency: %v", err)
|
|
}
|
|
|
|
// findRepliesTo should NOT find msg1 (blocks dependency, not replies-to)
|
|
parent := findRepliesTo(ctx, msg2.ID, nil, testStore)
|
|
if parent != "" {
|
|
t.Errorf("findRepliesTo(msg2) = %q, want empty (blocks dep should be ignored)", parent)
|
|
}
|
|
|
|
// findReplies from msg1 should NOT find msg2 (blocks dependency, not replies-to)
|
|
replies := findReplies(ctx, msg1.ID, nil, testStore)
|
|
if len(replies) != 0 {
|
|
t.Errorf("findReplies(msg1) returned %d replies, want 0 (blocks dep should be ignored)", len(replies))
|
|
}
|
|
}
|