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>
600 lines
15 KiB
Go
600 lines
15 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestRelatesTo verifies relates-to dependencies work via the dependency API.
|
|
// Per Decision 004, relates-to links are now stored in the dependencies table.
|
|
func TestRelatesTo(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create two issues
|
|
issue1 := &types.Issue{
|
|
Title: "Issue 1",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
issue2 := &types.Issue{
|
|
Title: "Issue 2",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue1: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue2: %v", err)
|
|
}
|
|
|
|
// Add relates-to dependency (bidirectional)
|
|
dep1 := &types.Dependency{
|
|
IssueID: issue1.ID,
|
|
DependsOnID: issue2.ID,
|
|
Type: types.DepRelatesTo,
|
|
}
|
|
if err := store.AddDependency(ctx, dep1, "test"); err != nil {
|
|
t.Fatalf("Failed to add relates-to dep1: %v", err)
|
|
}
|
|
dep2 := &types.Dependency{
|
|
IssueID: issue2.ID,
|
|
DependsOnID: issue1.ID,
|
|
Type: types.DepRelatesTo,
|
|
}
|
|
if err := store.AddDependency(ctx, dep2, "test"); err != nil {
|
|
t.Fatalf("Failed to add relates-to dep2: %v", err)
|
|
}
|
|
|
|
// Verify links via GetDependenciesWithMetadata
|
|
deps1, err := store.GetDependenciesWithMetadata(ctx, issue1.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
|
|
}
|
|
found1 := false
|
|
for _, d := range deps1 {
|
|
if d.ID == issue2.ID && d.DependencyType == types.DepRelatesTo {
|
|
found1 = true
|
|
}
|
|
}
|
|
if !found1 {
|
|
t.Errorf("issue1 should have relates-to link to issue2")
|
|
}
|
|
|
|
deps2, err := store.GetDependenciesWithMetadata(ctx, issue2.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
|
|
}
|
|
found2 := false
|
|
for _, d := range deps2 {
|
|
if d.ID == issue1.ID && d.DependencyType == types.DepRelatesTo {
|
|
found2 = true
|
|
}
|
|
}
|
|
if !found2 {
|
|
t.Errorf("issue2 should have relates-to link to issue1")
|
|
}
|
|
}
|
|
|
|
func TestRelatesTo_MultipleLinks(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create three issues
|
|
issues := make([]*types.Issue, 3)
|
|
for i := range issues {
|
|
issues[i] = &types.Issue{
|
|
Title: "Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, issues[i], "test"); err != nil {
|
|
t.Fatalf("Failed to create issue %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Link issue0 to both issue1 and issue2
|
|
for _, targetIssue := range []*types.Issue{issues[1], issues[2]} {
|
|
dep := &types.Dependency{
|
|
IssueID: issues[0].ID,
|
|
DependsOnID: targetIssue.ID,
|
|
Type: types.DepRelatesTo,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add relates-to: %v", err)
|
|
}
|
|
}
|
|
|
|
// Verify
|
|
deps, err := store.GetDependenciesWithMetadata(ctx, issues[0].ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
|
|
}
|
|
relatesCount := 0
|
|
for _, d := range deps {
|
|
if d.DependencyType == types.DepRelatesTo {
|
|
relatesCount++
|
|
}
|
|
}
|
|
if relatesCount != 2 {
|
|
t.Errorf("RelatesTo has %d links, want 2", relatesCount)
|
|
}
|
|
}
|
|
|
|
// TestDuplicateOf verifies duplicates dependencies work via the dependency API.
|
|
func TestDuplicateOf(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create canonical and duplicate issues
|
|
canonical := &types.Issue{
|
|
Title: "Canonical Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
duplicate := &types.Issue{
|
|
Title: "Duplicate Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, canonical, "test"); err != nil {
|
|
t.Fatalf("Failed to create canonical: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, duplicate, "test"); err != nil {
|
|
t.Fatalf("Failed to create duplicate: %v", err)
|
|
}
|
|
|
|
// Add duplicates dependency
|
|
dep := &types.Dependency{
|
|
IssueID: duplicate.ID,
|
|
DependsOnID: canonical.ID,
|
|
Type: types.DepDuplicates,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add duplicates dep: %v", err)
|
|
}
|
|
|
|
// Close the duplicate
|
|
if err := store.CloseIssue(ctx, duplicate.ID, "Closed as duplicate", "test", ""); err != nil {
|
|
t.Fatalf("Failed to close duplicate: %v", err)
|
|
}
|
|
|
|
// Verify dependency
|
|
deps, err := store.GetDependenciesWithMetadata(ctx, duplicate.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
|
|
}
|
|
found := false
|
|
for _, d := range deps {
|
|
if d.ID == canonical.ID && d.DependencyType == types.DepDuplicates {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("duplicate should have duplicates link to canonical")
|
|
}
|
|
|
|
// Verify closed status
|
|
updated, err := store.GetIssue(ctx, duplicate.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if updated.Status != types.StatusClosed {
|
|
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
|
|
}
|
|
}
|
|
|
|
// TestSupersededBy verifies supersedes dependencies work via the dependency API.
|
|
func TestSupersededBy(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create old and new versions
|
|
oldVersion := &types.Issue{
|
|
Title: "Design Doc v1",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
newVersion := &types.Issue{
|
|
Title: "Design Doc v2",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, oldVersion, "test"); err != nil {
|
|
t.Fatalf("Failed to create old version: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, newVersion, "test"); err != nil {
|
|
t.Fatalf("Failed to create new version: %v", err)
|
|
}
|
|
|
|
// Add supersedes dependency (newVersion supersedes oldVersion)
|
|
// Stored as: oldVersion depends on newVersion with type supersedes
|
|
dep := &types.Dependency{
|
|
IssueID: oldVersion.ID,
|
|
DependsOnID: newVersion.ID,
|
|
Type: types.DepSupersedes,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add supersedes dep: %v", err)
|
|
}
|
|
|
|
// Close old version
|
|
if err := store.CloseIssue(ctx, oldVersion.ID, "Superseded by v2", "test", ""); err != nil {
|
|
t.Fatalf("Failed to close old version: %v", err)
|
|
}
|
|
|
|
// Verify dependency
|
|
deps, err := store.GetDependenciesWithMetadata(ctx, oldVersion.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
|
|
}
|
|
found := false
|
|
for _, d := range deps {
|
|
if d.ID == newVersion.ID && d.DependencyType == types.DepSupersedes {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("oldVersion should have supersedes link to newVersion")
|
|
}
|
|
|
|
// Verify closed status
|
|
updated, err := store.GetIssue(ctx, oldVersion.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if updated.Status != types.StatusClosed {
|
|
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
|
|
}
|
|
}
|
|
|
|
// TestRepliesTo verifies replies-to dependencies work via the dependency API.
|
|
func TestRepliesTo(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create original message and reply
|
|
original := &types.Issue{
|
|
Title: "Original Message",
|
|
Description: "Original content",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Sender: "alice",
|
|
Assignee: "bob",
|
|
Ephemeral: true,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
reply := &types.Issue{
|
|
Title: "Re: Original Message",
|
|
Description: "Reply content",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Sender: "bob",
|
|
Assignee: "alice",
|
|
Ephemeral: true,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, original, "test"); err != nil {
|
|
t.Fatalf("Failed to create original: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, reply, "test"); err != nil {
|
|
t.Fatalf("Failed to create reply: %v", err)
|
|
}
|
|
|
|
// Add replies-to dependency
|
|
dep := &types.Dependency{
|
|
IssueID: reply.ID,
|
|
DependsOnID: original.ID,
|
|
Type: types.DepRepliesTo,
|
|
ThreadID: original.ID, // Thread root is the original message
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add replies-to dep: %v", err)
|
|
}
|
|
|
|
// Verify thread link
|
|
deps, err := store.GetDependenciesWithMetadata(ctx, reply.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependenciesWithMetadata failed: %v", err)
|
|
}
|
|
found := false
|
|
for _, d := range deps {
|
|
if d.ID == original.ID && d.DependencyType == types.DepRepliesTo {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("reply should have replies-to link to original")
|
|
}
|
|
}
|
|
|
|
func TestRepliesTo_Chain(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create a chain of replies
|
|
messages := make([]*types.Issue, 3)
|
|
var prevID string
|
|
|
|
for i := range messages {
|
|
messages[i] = &types.Issue{
|
|
Title: "Message",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Sender: "user",
|
|
Assignee: "inbox",
|
|
Ephemeral: true,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, messages[i], "test"); err != nil {
|
|
t.Fatalf("Failed to create message %d: %v", i, err)
|
|
}
|
|
|
|
// Add replies-to dependency for subsequent messages
|
|
if prevID != "" {
|
|
dep := &types.Dependency{
|
|
IssueID: messages[i].ID,
|
|
DependsOnID: prevID,
|
|
Type: types.DepRepliesTo,
|
|
ThreadID: messages[0].ID, // Thread root is the first message
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add replies-to dep for message %d: %v", i, err)
|
|
}
|
|
}
|
|
prevID = messages[i].ID
|
|
}
|
|
|
|
// Verify chain by checking dependents
|
|
for i := 0; i < len(messages)-1; i++ {
|
|
dependents, err := store.GetDependentsWithMetadata(ctx, messages[i].ID)
|
|
if err != nil {
|
|
t.Fatalf("GetDependentsWithMetadata failed for message %d: %v", i, err)
|
|
}
|
|
found := false
|
|
for _, d := range dependents {
|
|
if d.ID == messages[i+1].ID && d.DependencyType == types.DepRepliesTo {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Message %d should have reply from message %d", i, i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWispField(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create wisp issue
|
|
wisp := &types.Issue{
|
|
Title: "Wisp Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Ephemeral: true,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Create non-wisp issue
|
|
permanent := &types.Issue{
|
|
Title: "Permanent Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: false,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, wisp, "test"); err != nil {
|
|
t.Fatalf("Failed to create wisp: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, permanent, "test"); err != nil {
|
|
t.Fatalf("Failed to create permanent: %v", err)
|
|
}
|
|
|
|
// Verify wisp flag
|
|
savedWisp, err := store.GetIssue(ctx, wisp.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if !savedWisp.Ephemeral {
|
|
t.Error("Wisp issue should have Wisp=true")
|
|
}
|
|
|
|
savedPermanent, err := store.GetIssue(ctx, permanent.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if savedPermanent.Ephemeral {
|
|
t.Error("Permanent issue should have Wisp=false")
|
|
}
|
|
}
|
|
|
|
func TestWispFilter(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create mix of wisp and non-wisp issues
|
|
for i := 0; i < 3; i++ {
|
|
wisp := &types.Issue{
|
|
Title: "Wisp",
|
|
Status: types.StatusClosed, // Closed for cleanup test
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Ephemeral: true,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, wisp, "test"); err != nil {
|
|
t.Fatalf("Failed to create wisp %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
for i := 0; i < 2; i++ {
|
|
permanent := &types.Issue{
|
|
Title: "Permanent",
|
|
Status: types.StatusClosed,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: false,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, permanent, "test"); err != nil {
|
|
t.Fatalf("Failed to create permanent %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Filter for wisp only
|
|
wispTrue := true
|
|
closedStatus := types.StatusClosed
|
|
wispFilter := types.IssueFilter{
|
|
Status: &closedStatus,
|
|
Ephemeral: &wispTrue,
|
|
}
|
|
|
|
wispIssues, err := store.SearchIssues(ctx, "", wispFilter)
|
|
if err != nil {
|
|
t.Fatalf("SearchIssues failed: %v", err)
|
|
}
|
|
if len(wispIssues) != 3 {
|
|
t.Errorf("Expected 3 wisp issues, got %d", len(wispIssues))
|
|
}
|
|
|
|
// Filter for non-wisp only
|
|
wispFalse := false
|
|
nonWispFilter := types.IssueFilter{
|
|
Status: &closedStatus,
|
|
Ephemeral: &wispFalse,
|
|
}
|
|
|
|
permanentIssues, err := store.SearchIssues(ctx, "", nonWispFilter)
|
|
if err != nil {
|
|
t.Fatalf("SearchIssues failed: %v", err)
|
|
}
|
|
if len(permanentIssues) != 2 {
|
|
t.Errorf("Expected 2 non-wisp issues, got %d", len(permanentIssues))
|
|
}
|
|
}
|
|
|
|
func TestSenderField(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create issue with sender
|
|
msg := &types.Issue{
|
|
Title: "Message",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
Sender: "alice@example.com",
|
|
Assignee: "bob@example.com",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, msg, "test"); err != nil {
|
|
t.Fatalf("Failed to create message: %v", err)
|
|
}
|
|
|
|
// Verify sender is preserved
|
|
saved, err := store.GetIssue(ctx, msg.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if saved.Sender != "alice@example.com" {
|
|
t.Errorf("Sender = %q, want %q", saved.Sender, "alice@example.com")
|
|
}
|
|
}
|
|
|
|
func TestMessageType(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create a message type issue
|
|
msg := &types.Issue{
|
|
Title: "Test Message",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: "message",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, msg, "test"); err != nil {
|
|
t.Fatalf("Failed to create message: %v", err)
|
|
}
|
|
|
|
// Verify type is preserved
|
|
saved, err := store.GetIssue(ctx, msg.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssue failed: %v", err)
|
|
}
|
|
if saved.IssueType != "message" {
|
|
t.Errorf("IssueType = %q, want %q", saved.IssueType, "message")
|
|
}
|
|
|
|
// Filter by message type
|
|
messageType := types.IssueType("message")
|
|
filter := types.IssueFilter{
|
|
IssueType: &messageType,
|
|
}
|
|
|
|
messages, err := store.SearchIssues(ctx, "", filter)
|
|
if err != nil {
|
|
t.Fatalf("SearchIssues failed: %v", err)
|
|
}
|
|
if len(messages) != 1 {
|
|
t.Errorf("Expected 1 message, got %d", len(messages))
|
|
}
|
|
}
|