package beads import ( "os" "path/filepath" "strings" "testing" ) // TestNew verifies the constructor. func TestNew(t *testing.T) { b := New("/some/path") if b == nil { t.Fatal("New returned nil") } if b.workDir != "/some/path" { t.Errorf("workDir = %q, want /some/path", b.workDir) } } // TestListOptions verifies ListOptions defaults. func TestListOptions(t *testing.T) { opts := ListOptions{ Status: "open", Type: "task", Priority: 1, } if opts.Status != "open" { t.Errorf("Status = %q, want open", opts.Status) } } // TestCreateOptions verifies CreateOptions fields. func TestCreateOptions(t *testing.T) { opts := CreateOptions{ Title: "Test issue", Type: "task", Priority: 2, Description: "A test description", Parent: "gt-abc", } if opts.Title != "Test issue" { t.Errorf("Title = %q, want 'Test issue'", opts.Title) } if opts.Parent != "gt-abc" { t.Errorf("Parent = %q, want gt-abc", opts.Parent) } } // TestUpdateOptions verifies UpdateOptions pointer fields. func TestUpdateOptions(t *testing.T) { status := "in_progress" priority := 1 opts := UpdateOptions{ Status: &status, Priority: &priority, } if *opts.Status != "in_progress" { t.Errorf("Status = %q, want in_progress", *opts.Status) } if *opts.Priority != 1 { t.Errorf("Priority = %d, want 1", *opts.Priority) } } // TestIsBeadsRepo tests repository detection. func TestIsBeadsRepo(t *testing.T) { // Test with a non-beads directory tmpDir, err := os.MkdirTemp("", "beads-test-*") if err != nil { t.Fatal(err) } defer func() { _ = os.RemoveAll(tmpDir) }() b := New(tmpDir) // This should return false since there's no .beads directory // and bd list will fail if b.IsBeadsRepo() { // This might pass if bd handles missing .beads gracefully t.Log("IsBeadsRepo returned true for non-beads directory (bd might initialize)") } } // TestWrapError tests error wrapping. func TestWrapError(t *testing.T) { b := New("/test") tests := []struct { stderr string wantErr error wantNil bool }{ {"not a beads repository", ErrNotARepo, false}, {"No .beads directory found", ErrNotARepo, false}, {".beads directory not found", ErrNotARepo, false}, {"sync conflict detected", ErrSyncConflict, false}, {"CONFLICT in file.md", ErrSyncConflict, false}, {"Issue not found: gt-xyz", ErrNotFound, false}, {"gt-xyz not found", ErrNotFound, false}, } for _, tt := range tests { err := b.wrapError(nil, tt.stderr, []string{"test"}) if tt.wantNil { if err != nil { t.Errorf("wrapError(%q) = %v, want nil", tt.stderr, err) } } else { if err != tt.wantErr { t.Errorf("wrapError(%q) = %v, want %v", tt.stderr, err, tt.wantErr) } } } } // Integration test that runs against real bd if available func TestIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } // Find a beads repo (use current directory if it has .beads) cwd, err := os.Getwd() if err != nil { t.Fatal(err) } // Walk up to find .beads dir := cwd for { if _, err := os.Stat(filepath.Join(dir, ".beads")); err == nil { break } parent := filepath.Dir(dir) if parent == dir { t.Skip("no .beads directory found in path") } dir = parent } b := New(dir) // Test List t.Run("List", func(t *testing.T) { issues, err := b.List(ListOptions{Status: "open"}) if err != nil { t.Fatalf("List failed: %v", err) } t.Logf("Found %d open issues", len(issues)) }) // Test Ready t.Run("Ready", func(t *testing.T) { issues, err := b.Ready() if err != nil { t.Fatalf("Ready failed: %v", err) } t.Logf("Found %d ready issues", len(issues)) }) // Test Blocked t.Run("Blocked", func(t *testing.T) { issues, err := b.Blocked() if err != nil { t.Fatalf("Blocked failed: %v", err) } t.Logf("Found %d blocked issues", len(issues)) }) // Test Show (if we have issues) t.Run("Show", func(t *testing.T) { issues, err := b.List(ListOptions{}) if err != nil { t.Fatalf("List failed: %v", err) } if len(issues) == 0 { t.Skip("no issues to show") } issue, err := b.Show(issues[0].ID) if err != nil { t.Fatalf("Show(%s) failed: %v", issues[0].ID, err) } t.Logf("Showed issue: %s - %s", issue.ID, issue.Title) }) } // TestParseMRFields tests parsing MR fields from issue descriptions. func TestParseMRFields(t *testing.T) { tests := []struct { name string issue *Issue wantNil bool wantFields *MRFields }{ { name: "nil issue", issue: nil, wantNil: true, }, { name: "empty description", issue: &Issue{Description: ""}, wantNil: true, }, { name: "no MR fields", issue: &Issue{Description: "This is just plain text\nwith no field markers"}, wantNil: true, }, { name: "all fields", issue: &Issue{ Description: `branch: polecat/Nux/gt-xyz target: main source_issue: gt-xyz worker: Nux rig: gastown merge_commit: abc123def close_reason: merged`, }, wantFields: &MRFields{ Branch: "polecat/Nux/gt-xyz", Target: "main", SourceIssue: "gt-xyz", Worker: "Nux", Rig: "gastown", MergeCommit: "abc123def", CloseReason: "merged", }, }, { name: "partial fields", issue: &Issue{ Description: `branch: polecat/Toast/gt-abc target: integration/gt-epic source_issue: gt-abc worker: Toast`, }, wantFields: &MRFields{ Branch: "polecat/Toast/gt-abc", Target: "integration/gt-epic", SourceIssue: "gt-abc", Worker: "Toast", }, }, { name: "mixed with prose", issue: &Issue{ Description: `branch: polecat/Capable/gt-def target: main source_issue: gt-def This MR fixes a critical bug in the authentication system. Please review carefully. worker: Capable rig: wasteland`, }, wantFields: &MRFields{ Branch: "polecat/Capable/gt-def", Target: "main", SourceIssue: "gt-def", Worker: "Capable", Rig: "wasteland", }, }, { name: "alternate key formats", issue: &Issue{ Description: `branch: polecat/Max/gt-ghi source-issue: gt-ghi merge-commit: 789xyz`, }, wantFields: &MRFields{ Branch: "polecat/Max/gt-ghi", SourceIssue: "gt-ghi", MergeCommit: "789xyz", }, }, { name: "case insensitive keys", issue: &Issue{ Description: `Branch: polecat/Furiosa/gt-jkl TARGET: main Worker: Furiosa RIG: gastown`, }, wantFields: &MRFields{ Branch: "polecat/Furiosa/gt-jkl", Target: "main", Worker: "Furiosa", Rig: "gastown", }, }, { name: "extra whitespace", issue: &Issue{ Description: ` branch: polecat/Nux/gt-mno target:main worker: Nux `, }, wantFields: &MRFields{ Branch: "polecat/Nux/gt-mno", Target: "main", Worker: "Nux", }, }, { name: "ignores empty values", issue: &Issue{ Description: `branch: polecat/Nux/gt-pqr target: source_issue: gt-pqr`, }, wantFields: &MRFields{ Branch: "polecat/Nux/gt-pqr", SourceIssue: "gt-pqr", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fields := ParseMRFields(tt.issue) if tt.wantNil { if fields != nil { t.Errorf("ParseMRFields() = %+v, want nil", fields) } return } if fields == nil { t.Fatal("ParseMRFields() = nil, want non-nil") } if fields.Branch != tt.wantFields.Branch { t.Errorf("Branch = %q, want %q", fields.Branch, tt.wantFields.Branch) } if fields.Target != tt.wantFields.Target { t.Errorf("Target = %q, want %q", fields.Target, tt.wantFields.Target) } if fields.SourceIssue != tt.wantFields.SourceIssue { t.Errorf("SourceIssue = %q, want %q", fields.SourceIssue, tt.wantFields.SourceIssue) } if fields.Worker != tt.wantFields.Worker { t.Errorf("Worker = %q, want %q", fields.Worker, tt.wantFields.Worker) } if fields.Rig != tt.wantFields.Rig { t.Errorf("Rig = %q, want %q", fields.Rig, tt.wantFields.Rig) } if fields.MergeCommit != tt.wantFields.MergeCommit { t.Errorf("MergeCommit = %q, want %q", fields.MergeCommit, tt.wantFields.MergeCommit) } if fields.CloseReason != tt.wantFields.CloseReason { t.Errorf("CloseReason = %q, want %q", fields.CloseReason, tt.wantFields.CloseReason) } }) } } // TestFormatMRFields tests formatting MR fields to string. func TestFormatMRFields(t *testing.T) { tests := []struct { name string fields *MRFields want string }{ { name: "nil fields", fields: nil, want: "", }, { name: "empty fields", fields: &MRFields{}, want: "", }, { name: "all fields", fields: &MRFields{ Branch: "polecat/Nux/gt-xyz", Target: "main", SourceIssue: "gt-xyz", Worker: "Nux", Rig: "gastown", MergeCommit: "abc123def", CloseReason: "merged", }, want: `branch: polecat/Nux/gt-xyz target: main source_issue: gt-xyz worker: Nux rig: gastown merge_commit: abc123def close_reason: merged`, }, { name: "partial fields", fields: &MRFields{ Branch: "polecat/Toast/gt-abc", Target: "main", SourceIssue: "gt-abc", Worker: "Toast", }, want: `branch: polecat/Toast/gt-abc target: main source_issue: gt-abc worker: Toast`, }, { name: "only close fields", fields: &MRFields{ MergeCommit: "deadbeef", CloseReason: "rejected", }, want: `merge_commit: deadbeef close_reason: rejected`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatMRFields(tt.fields) if got != tt.want { t.Errorf("FormatMRFields() =\n%q\nwant\n%q", got, tt.want) } }) } } // TestSetMRFields tests updating issue descriptions with MR fields. func TestSetMRFields(t *testing.T) { tests := []struct { name string issue *Issue fields *MRFields want string }{ { name: "nil issue", issue: nil, fields: &MRFields{ Branch: "polecat/Nux/gt-xyz", Target: "main", }, want: `branch: polecat/Nux/gt-xyz target: main`, }, { name: "empty description", issue: &Issue{Description: ""}, fields: &MRFields{ Branch: "polecat/Nux/gt-xyz", Target: "main", SourceIssue: "gt-xyz", }, want: `branch: polecat/Nux/gt-xyz target: main source_issue: gt-xyz`, }, { name: "preserve prose content", issue: &Issue{Description: "This is a description of the work.\n\nIt spans multiple lines."}, fields: &MRFields{ Branch: "polecat/Toast/gt-abc", Worker: "Toast", }, want: `branch: polecat/Toast/gt-abc worker: Toast This is a description of the work. It spans multiple lines.`, }, { name: "replace existing fields", issue: &Issue{ Description: `branch: polecat/Nux/gt-old target: develop source_issue: gt-old worker: Nux Some existing prose content.`, }, fields: &MRFields{ Branch: "polecat/Nux/gt-new", Target: "main", SourceIssue: "gt-new", Worker: "Nux", MergeCommit: "abc123", }, want: `branch: polecat/Nux/gt-new target: main source_issue: gt-new worker: Nux merge_commit: abc123 Some existing prose content.`, }, { name: "preserve non-MR key-value lines", issue: &Issue{ Description: `branch: polecat/Capable/gt-def custom_field: some value author: someone target: main`, }, fields: &MRFields{ Branch: "polecat/Capable/gt-ghi", Target: "integration/epic", CloseReason: "merged", }, want: `branch: polecat/Capable/gt-ghi target: integration/epic close_reason: merged custom_field: some value author: someone`, }, { name: "empty fields clears MR data", issue: &Issue{Description: "branch: old\ntarget: old\n\nKeep this text."}, fields: &MRFields{}, want: "Keep this text.", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := SetMRFields(tt.issue, tt.fields) if got != tt.want { t.Errorf("SetMRFields() =\n%q\nwant\n%q", got, tt.want) } }) } } // TestMRFieldsRoundTrip tests that parse/format round-trips correctly. func TestMRFieldsRoundTrip(t *testing.T) { original := &MRFields{ Branch: "polecat/Nux/gt-xyz", Target: "main", SourceIssue: "gt-xyz", Worker: "Nux", Rig: "gastown", MergeCommit: "abc123def789", CloseReason: "merged", } // Format to string formatted := FormatMRFields(original) // Parse back issue := &Issue{Description: formatted} parsed := ParseMRFields(issue) if parsed == nil { t.Fatal("round-trip parse returned nil") } if *parsed != *original { t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original) } } // TestParseMRFieldsFromDesignDoc tests the example from the design doc. func TestParseMRFieldsFromDesignDoc(t *testing.T) { // Example from docs/merge-queue-design.md description := `branch: polecat/Nux/gt-xyz target: main source_issue: gt-xyz worker: Nux rig: gastown` issue := &Issue{Description: description} fields := ParseMRFields(issue) if fields == nil { t.Fatal("ParseMRFields returned nil for design doc example") } // Verify all fields match the design doc if fields.Branch != "polecat/Nux/gt-xyz" { t.Errorf("Branch = %q, want polecat/Nux/gt-xyz", fields.Branch) } if fields.Target != "main" { t.Errorf("Target = %q, want main", fields.Target) } if fields.SourceIssue != "gt-xyz" { t.Errorf("SourceIssue = %q, want gt-xyz", fields.SourceIssue) } if fields.Worker != "Nux" { t.Errorf("Worker = %q, want Nux", fields.Worker) } if fields.Rig != "gastown" { t.Errorf("Rig = %q, want gastown", fields.Rig) } } // TestSetMRFieldsPreservesURL tests that URLs in prose are preserved. func TestSetMRFieldsPreservesURL(t *testing.T) { // URLs contain colons which could be confused with key: value issue := &Issue{ Description: `branch: old-branch Check out https://example.com/path for more info. Also see http://localhost:8080/api`, } fields := &MRFields{ Branch: "new-branch", Target: "main", } result := SetMRFields(issue, fields) // URLs should be preserved if !strings.Contains(result, "https://example.com/path") { t.Error("HTTPS URL was not preserved") } if !strings.Contains(result, "http://localhost:8080/api") { t.Error("HTTP URL was not preserved") } if !strings.Contains(result, "branch: new-branch") { t.Error("branch field was not set") } }