* feat: enhance bd doctor sync detection with count and prefix mismatch checks Improves bd doctor to detect actual database-JSONL sync issues instead of relying only on file modification times: Key improvements: 1. Count detection: Reports when database issue count differs from JSONL (e.g., "Count mismatch: database has 0 issues, JSONL has 61") 2. Prefix detection: Identifies prefix mismatches when majority of JSONL issues use different prefix than database config 3. Error handling: Returns errors from helper functions instead of silent failures, distinguishing "can't open DB" from "counts differ" 4. Query optimization: Single database connection for all checks (reduced from 3 opens to 1) 5. Better error reporting: Shows actual error details when database or JSONL can't be read This addresses the core issue where bd doctor would incorrectly report "Database and JSONL are in sync" when the database was empty but JSONL contained issues (as happened in privacy2 project). Tests: - Added TestCountJSONLIssuesWithMalformedLines to verify malformed JSON handling - Existing doctor tests still pass - countJSONLIssues now returns error to indicate parsing issues 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: correct git hooks installation instructions in bd doctor The original message referenced './examples/git-hooks/install.sh' which doesn't exist in user projects. This fix changes the message to point to the actual location in the beads GitHub repository: Before: "Run './examples/git-hooks/install.sh' to install recommended git hooks" After: "See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions" This works for any project using bd, not just the beads repository itself. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * feat: add recovery suggestions when database fails but JSONL has issues When bd doctor detects that the database cannot be opened/queried but the JSONL file contains issues, it now suggests the recovery command: Fix: Run 'bd import -i issues.jsonl --rename-on-import' to recover issues from JSONL This addresses the case where: - Database is corrupted or inaccessible - JSONL has all the issues backed up - User needs a clear path to recover The check now: 1. Reads JSONL first (doesn't depend on database) 2. If database fails but JSONL has issues, suggests recovery command 3. If database can be queried, continues with sync checks as before Tested on privacy2 project which has 61 issues in JSONL but inaccessible database. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: support hash-based issue IDs in import rename The import --rename-on-import flag was rejecting valid issue IDs with hash-based suffixes (e.g., privacy-09ea) because the validation only accepted numeric suffixes. Beads now generates and accepts base36-encoded hash IDs, so update the validation to match. Changes: - Update isNumeric() to accept base36 characters (0-9, a-z) - Update tests to reflect hash-based ID support - Add gosec nolint comment for safe file path construction Fixes the error: "cannot rename issue privacy-09ea: non-numeric suffix '09ea'" --------- Co-authored-by: Claude <noreply@anthropic.com>
1122 lines
27 KiB
Go
1122 lines
27 KiB
Go
package importer
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestIssueDataChanged(t *testing.T) {
|
|
baseIssue := &types.Issue{
|
|
ID: "test-1",
|
|
Title: "Original Title",
|
|
Description: "Original Description",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
Design: "Design notes",
|
|
AcceptanceCriteria: "Acceptance",
|
|
Notes: "Notes",
|
|
Assignee: "john",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
updates map[string]interface{}
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "no changes",
|
|
updates: map[string]interface{}{
|
|
"title": "Original Title",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "title changed",
|
|
updates: map[string]interface{}{
|
|
"title": "New Title",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "description changed",
|
|
updates: map[string]interface{}{
|
|
"description": "New Description",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "status changed",
|
|
updates: map[string]interface{}{
|
|
"status": types.StatusClosed,
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "status string changed",
|
|
updates: map[string]interface{}{
|
|
"status": "closed",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "priority changed",
|
|
updates: map[string]interface{}{
|
|
"priority": 2,
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "priority float64 changed",
|
|
updates: map[string]interface{}{
|
|
"priority": float64(2),
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "issue_type changed",
|
|
updates: map[string]interface{}{
|
|
"issue_type": types.TypeBug,
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "design changed",
|
|
updates: map[string]interface{}{
|
|
"design": "New design",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "acceptance_criteria changed",
|
|
updates: map[string]interface{}{
|
|
"acceptance_criteria": "New acceptance",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "notes changed",
|
|
updates: map[string]interface{}{
|
|
"notes": "New notes",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "assignee changed",
|
|
updates: map[string]interface{}{
|
|
"assignee": "jane",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "multiple fields same",
|
|
updates: map[string]interface{}{
|
|
"title": "Original Title",
|
|
"priority": 1,
|
|
"status": types.StatusOpen,
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "one field changed in multiple",
|
|
updates: map[string]interface{}{
|
|
"title": "Original Title",
|
|
"priority": 2, // Changed
|
|
"status": types.StatusOpen,
|
|
},
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := IssueDataChanged(baseIssue, tt.updates)
|
|
if result != tt.expected {
|
|
t.Errorf("Expected %v, got %v", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldComparator_StringConversion(t *testing.T) {
|
|
fc := newFieldComparator()
|
|
|
|
tests := []struct {
|
|
name string
|
|
value interface{}
|
|
wantStr string
|
|
wantOk bool
|
|
}{
|
|
{"string", "hello", "hello", true},
|
|
{"string pointer", stringPtr("world"), "world", true},
|
|
{"nil string pointer", (*string)(nil), "", true},
|
|
{"nil", nil, "", true},
|
|
{"int (invalid)", 123, "", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
str, ok := fc.strFrom(tt.value)
|
|
if ok != tt.wantOk {
|
|
t.Errorf("Expected ok=%v, got ok=%v", tt.wantOk, ok)
|
|
}
|
|
if ok && str != tt.wantStr {
|
|
t.Errorf("Expected str=%q, got %q", tt.wantStr, str)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldComparator_EqualPtrStr(t *testing.T) {
|
|
fc := newFieldComparator()
|
|
|
|
tests := []struct {
|
|
name string
|
|
existing *string
|
|
newVal interface{}
|
|
want bool
|
|
}{
|
|
{"both nil", nil, "", true},
|
|
{"existing nil, new empty", nil, "", true},
|
|
{"existing nil, new string", nil, "test", false},
|
|
{"equal strings", stringPtr("test"), "test", true},
|
|
{"different strings", stringPtr("test"), "other", false},
|
|
{"existing string, new nil", stringPtr("test"), nil, false},
|
|
{"invalid type", stringPtr("test"), 123, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := fc.equalPtrStr(tt.existing, tt.newVal)
|
|
if got != tt.want {
|
|
t.Errorf("equalPtrStr() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldComparator_EqualIssueType(t *testing.T) {
|
|
fc := newFieldComparator()
|
|
|
|
tests := []struct {
|
|
name string
|
|
existing types.IssueType
|
|
newVal interface{}
|
|
want bool
|
|
}{
|
|
{"same IssueType", types.TypeTask, types.TypeTask, true},
|
|
{"different IssueType", types.TypeTask, types.TypeBug, false},
|
|
{"IssueType vs string match", types.TypeTask, "task", true},
|
|
{"IssueType vs string no match", types.TypeTask, "bug", false},
|
|
{"invalid type", types.TypeTask, 123, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := fc.equalIssueType(tt.existing, tt.newVal)
|
|
if got != tt.want {
|
|
t.Errorf("equalIssueType() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFieldComparator_IntConversion(t *testing.T) {
|
|
fc := newFieldComparator()
|
|
|
|
tests := []struct {
|
|
name string
|
|
value interface{}
|
|
wantInt int64
|
|
wantOk bool
|
|
}{
|
|
{"int", 42, 42, true},
|
|
{"int32", int32(42), 42, true},
|
|
{"int64", int64(42), 42, true},
|
|
{"float64 integer", float64(42), 42, true},
|
|
{"float64 fractional", 42.5, 0, false},
|
|
{"string (invalid)", "123", 0, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
i, ok := fc.intFrom(tt.value)
|
|
if ok != tt.wantOk {
|
|
t.Errorf("Expected ok=%v, got ok=%v", tt.wantOk, ok)
|
|
}
|
|
if ok && i != tt.wantInt {
|
|
t.Errorf("Expected int=%d, got %d", tt.wantInt, i)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenameImportedIssuePrefixes(t *testing.T) {
|
|
t.Run("rename single issue", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "old-1",
|
|
Title: "Test Issue",
|
|
},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "new")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if issues[0].ID != "new-1" {
|
|
t.Errorf("Expected ID 'new-1', got '%s'", issues[0].ID)
|
|
}
|
|
})
|
|
|
|
t.Run("rename multiple issues", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{ID: "old-1", Title: "Issue 1"},
|
|
{ID: "old-2", Title: "Issue 2"},
|
|
{ID: "other-3", Title: "Issue 3"},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "new")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if issues[0].ID != "new-1" {
|
|
t.Errorf("Expected ID 'new-1', got '%s'", issues[0].ID)
|
|
}
|
|
if issues[1].ID != "new-2" {
|
|
t.Errorf("Expected ID 'new-2', got '%s'", issues[1].ID)
|
|
}
|
|
if issues[2].ID != "new-3" {
|
|
t.Errorf("Expected ID 'new-3', got '%s'", issues[2].ID)
|
|
}
|
|
})
|
|
|
|
t.Run("rename with dependencies", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "old-1",
|
|
Title: "Issue 1",
|
|
Dependencies: []*types.Dependency{
|
|
{IssueID: "old-1", DependsOnID: "old-2", Type: types.DepBlocks},
|
|
},
|
|
},
|
|
{
|
|
ID: "old-2",
|
|
Title: "Issue 2",
|
|
},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "new")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if issues[0].Dependencies[0].IssueID != "new-1" {
|
|
t.Errorf("Expected dependency IssueID 'new-1', got '%s'", issues[0].Dependencies[0].IssueID)
|
|
}
|
|
if issues[0].Dependencies[0].DependsOnID != "new-2" {
|
|
t.Errorf("Expected dependency DependsOnID 'new-2', got '%s'", issues[0].Dependencies[0].DependsOnID)
|
|
}
|
|
})
|
|
|
|
t.Run("rename with text references", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "old-1",
|
|
Title: "Refers to old-2",
|
|
Description: "See old-2 for details",
|
|
Design: "Depends on old-2",
|
|
AcceptanceCriteria: "After old-2 is done",
|
|
Notes: "Related: old-2",
|
|
},
|
|
{
|
|
ID: "old-2",
|
|
Title: "Issue 2",
|
|
},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "new")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if issues[0].Title != "Refers to new-2" {
|
|
t.Errorf("Expected title with new-2, got '%s'", issues[0].Title)
|
|
}
|
|
if issues[0].Description != "See new-2 for details" {
|
|
t.Errorf("Expected description with new-2, got '%s'", issues[0].Description)
|
|
}
|
|
})
|
|
|
|
t.Run("rename with comments", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "old-1",
|
|
Title: "Issue 1",
|
|
Comments: []*types.Comment{
|
|
{
|
|
ID: 0,
|
|
IssueID: "old-1",
|
|
Author: "test",
|
|
Text: "Related to old-2",
|
|
CreatedAt: time.Now(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "old-2",
|
|
Title: "Issue 2",
|
|
},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "new")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if issues[0].Comments[0].Text != "Related to new-2" {
|
|
t.Errorf("Expected comment with new-2, got '%s'", issues[0].Comments[0].Text)
|
|
}
|
|
})
|
|
|
|
t.Run("error on malformed ID", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{ID: "nohyphen", Title: "Invalid"},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "new")
|
|
if err == nil {
|
|
t.Error("Expected error for malformed ID")
|
|
}
|
|
})
|
|
|
|
t.Run("hash-based suffix rename", func(t *testing.T) {
|
|
// Hash-based IDs (base36) are now valid and should be renamed
|
|
issues := []*types.Issue{
|
|
{ID: "old-a3f8", Title: "Hash suffix issue"},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "new")
|
|
if err != nil {
|
|
t.Errorf("Unexpected error for hash-based suffix: %v", err)
|
|
}
|
|
if issues[0].ID != "new-a3f8" {
|
|
t.Errorf("Expected ID 'new-a3f8', got %q", issues[0].ID)
|
|
}
|
|
})
|
|
|
|
t.Run("no rename when prefix matches", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{ID: "same-1", Title: "Issue 1"},
|
|
}
|
|
|
|
err := RenameImportedIssuePrefixes(issues, "same")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
|
|
if issues[0].ID != "same-1" {
|
|
t.Errorf("Expected ID unchanged 'same-1', got '%s'", issues[0].ID)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestReplaceBoundaryAware(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
text string
|
|
oldID string
|
|
newID string
|
|
want string
|
|
}{
|
|
{
|
|
name: "simple replacement",
|
|
text: "See old-1 for details",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "See new-1 for details",
|
|
},
|
|
{
|
|
name: "multiple occurrences",
|
|
text: "old-1 and old-1 again",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "new-1 and new-1 again",
|
|
},
|
|
{
|
|
name: "no match substring prefix",
|
|
text: "old-10 should not match",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "old-10 should not match",
|
|
},
|
|
{
|
|
name: "match at end of longer ID",
|
|
text: "should not match old-1 at end",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "should not match new-1 at end",
|
|
},
|
|
{
|
|
name: "boundary at start",
|
|
text: "old-1 starts here",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "new-1 starts here",
|
|
},
|
|
{
|
|
name: "boundary at end",
|
|
text: "ends with old-1",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "ends with new-1",
|
|
},
|
|
{
|
|
name: "boundary punctuation",
|
|
text: "See (old-1) and [old-1] or {old-1}",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "See (new-1) and [new-1] or {new-1}",
|
|
},
|
|
{
|
|
name: "no occurrence",
|
|
text: "No match here",
|
|
oldID: "old-1",
|
|
newID: "new-1",
|
|
want: "No match here",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := replaceBoundaryAware(tt.text, tt.oldID, tt.newID)
|
|
if got != tt.want {
|
|
t.Errorf("Got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsBoundary(t *testing.T) {
|
|
boundaries := []byte{' ', '\t', '\n', '\r', ',', '.', '!', '?', ':', ';', '(', ')', '[', ']', '{', '}'}
|
|
for _, b := range boundaries {
|
|
if !isBoundary(b) {
|
|
t.Errorf("Expected '%c' to be a boundary", b)
|
|
}
|
|
}
|
|
|
|
notBoundaries := []byte{'a', 'Z', '0', '9', '-', '_'}
|
|
for _, b := range notBoundaries {
|
|
if isBoundary(b) {
|
|
t.Errorf("Expected '%c' not to be a boundary", b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsNumeric(t *testing.T) {
|
|
tests := []struct {
|
|
s string
|
|
want bool
|
|
}{
|
|
// Numeric suffixes (traditional)
|
|
{"123", true},
|
|
{"0", true},
|
|
{"999", true},
|
|
// Hash-based suffixes (base36: 0-9, a-z)
|
|
{"a3f8e9", true},
|
|
{"09ea", true},
|
|
{"abc123", true},
|
|
{"zzz", true},
|
|
// Invalid suffixes
|
|
{"", false}, // Empty string now returns false
|
|
{"1.5", false}, // Non-base36 characters
|
|
{"A3F8", false}, // Uppercase not allowed
|
|
{"@#$!", false}, // Special characters not allowed
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.s, func(t *testing.T) {
|
|
got := isNumeric(tt.s)
|
|
if got != tt.want {
|
|
t.Errorf("isNumeric(%q) = %v, want %v", tt.s, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func stringPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
func TestImportIssues_Basic(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create temp database
|
|
tmpDB := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(tmpDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
// Set config prefix
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
// Import single issue
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "test-abc123",
|
|
Title: "Test Issue",
|
|
Description: "Test description",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
}
|
|
|
|
result, err := ImportIssues(ctx, tmpDB, store, issues, Options{})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
if result.Created != 1 {
|
|
t.Errorf("Expected 1 created, got %d", result.Created)
|
|
}
|
|
|
|
// Verify issue was created
|
|
retrieved, err := store.GetIssue(ctx, "test-abc123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve issue: %v", err)
|
|
}
|
|
if retrieved.Title != "Test Issue" {
|
|
t.Errorf("Expected title 'Test Issue', got '%s'", retrieved.Title)
|
|
}
|
|
}
|
|
|
|
func TestImportIssues_Update(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tmpDB := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(tmpDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
// Create initial issue
|
|
issue1 := &types.Issue{
|
|
ID: "test-abc123",
|
|
Title: "Original Title",
|
|
Description: "Original description",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
issue1.ContentHash = issue1.ComputeContentHash()
|
|
|
|
err = store.CreateIssue(ctx, issue1, "test")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create initial issue: %v", err)
|
|
}
|
|
|
|
// Import updated version with newer timestamp
|
|
issue2 := &types.Issue{
|
|
ID: "test-abc123",
|
|
Title: "Updated Title",
|
|
Description: "Updated description",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now().Add(time.Hour), // Newer than issue1
|
|
}
|
|
issue2.ContentHash = issue2.ComputeContentHash()
|
|
|
|
result, err := ImportIssues(ctx, tmpDB, store, []*types.Issue{issue2}, Options{})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
// The importer detects this as both a collision (1) and then upserts it (creates=1)
|
|
// Total updates = collision count + actual upserts
|
|
if result.Updated == 0 && result.Created == 0 {
|
|
t.Error("Expected some updates or creates")
|
|
}
|
|
|
|
// Verify update
|
|
retrieved, err := store.GetIssue(ctx, "test-abc123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve issue: %v", err)
|
|
}
|
|
if retrieved.Title != "Updated Title" {
|
|
t.Errorf("Expected title 'Updated Title', got '%s'", retrieved.Title)
|
|
}
|
|
}
|
|
|
|
func TestImportIssues_DryRun(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tmpDB := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(tmpDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "test-abc123",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
}
|
|
|
|
// Dry run returns early when no collisions, so it reports what would be created
|
|
result, err := ImportIssues(ctx, tmpDB, store, issues, Options{DryRun: true})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
// Should report that 1 issue would be created
|
|
if result.Created != 1 {
|
|
t.Errorf("Expected 1 would be created in dry run, got %d", result.Created)
|
|
}
|
|
}
|
|
|
|
func TestImportIssues_Dependencies(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tmpDB := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(tmpDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "test-abc123",
|
|
Title: "Issue 1",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
Dependencies: []*types.Dependency{
|
|
{IssueID: "test-abc123", DependsOnID: "test-def456", Type: types.DepBlocks},
|
|
},
|
|
},
|
|
{
|
|
ID: "test-def456",
|
|
Title: "Issue 2",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
}
|
|
|
|
result, err := ImportIssues(ctx, tmpDB, store, issues, Options{})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
if result.Created != 2 {
|
|
t.Errorf("Expected 2 created, got %d", result.Created)
|
|
}
|
|
|
|
// Verify dependency was created
|
|
deps, err := store.GetDependencies(ctx, "test-abc123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get dependencies: %v", err)
|
|
}
|
|
if len(deps) != 1 {
|
|
t.Errorf("Expected 1 dependency, got %d", len(deps))
|
|
}
|
|
}
|
|
|
|
func TestImportIssues_Labels(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tmpDB := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(tmpDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "test-abc123",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
Labels: []string{"bug", "critical"},
|
|
},
|
|
}
|
|
|
|
result, err := ImportIssues(ctx, tmpDB, store, issues, Options{})
|
|
if err != nil {
|
|
t.Fatalf("Import failed: %v", err)
|
|
}
|
|
|
|
if result.Created != 1 {
|
|
t.Errorf("Expected 1 created, got %d", result.Created)
|
|
}
|
|
|
|
// Verify labels were created
|
|
retrieved, err := store.GetIssue(ctx, "test-abc123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve issue: %v", err)
|
|
}
|
|
if len(retrieved.Labels) != 2 {
|
|
t.Errorf("Expected 2 labels, got %d", len(retrieved.Labels))
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateStore_ExistingStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tmpDB := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(tmpDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
result, needClose, err := getOrCreateStore(ctx, tmpDB, store)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
if needClose {
|
|
t.Error("Expected needClose=false for existing store")
|
|
}
|
|
if result != store {
|
|
t.Error("Expected same store instance")
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateStore_NewStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tmpDB := t.TempDir() + "/test.db"
|
|
|
|
// Create initial database
|
|
initStore, err := sqlite.New(tmpDB)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
initStore.Close()
|
|
|
|
// Test creating new connection
|
|
result, needClose, err := getOrCreateStore(ctx, tmpDB, nil)
|
|
if err != nil {
|
|
t.Fatalf("Expected no error, got: %v", err)
|
|
}
|
|
defer result.Close()
|
|
|
|
if !needClose {
|
|
t.Error("Expected needClose=true for new store")
|
|
}
|
|
if result == nil {
|
|
t.Error("Expected non-nil store")
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateStore_EmptyPath(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
_, _, err := getOrCreateStore(ctx, "", nil)
|
|
if err == nil {
|
|
t.Error("Expected error for empty database path")
|
|
}
|
|
}
|
|
|
|
func TestGetPrefixList(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prefixes map[string]int
|
|
want []string
|
|
}{
|
|
{
|
|
name: "single prefix",
|
|
prefixes: map[string]int{"test": 5},
|
|
want: []string{"test- (5 issues)"},
|
|
},
|
|
{
|
|
name: "multiple prefixes",
|
|
prefixes: map[string]int{"test": 3, "other": 2, "foo": 1},
|
|
want: []string{"foo- (1 issues)", "other- (2 issues)", "test- (3 issues)"},
|
|
},
|
|
{
|
|
name: "empty",
|
|
prefixes: map[string]int{},
|
|
want: []string{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := GetPrefixList(tt.prefixes)
|
|
if len(got) != len(tt.want) {
|
|
t.Errorf("Length mismatch: got %d, want %d", len(got), len(tt.want))
|
|
return
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.want[i] {
|
|
t.Errorf("Index %d: got %q, want %q", i, got[i], tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateNoDuplicateExternalRefs(t *testing.T) {
|
|
t.Run("no external_ref values", func(t *testing.T) {
|
|
issues := []*types.Issue{
|
|
{ID: "bd-1", Title: "Issue 1"},
|
|
{ID: "bd-2", Title: "Issue 2"},
|
|
}
|
|
err := validateNoDuplicateExternalRefs(issues)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("unique external_ref values", func(t *testing.T) {
|
|
ref1 := "JIRA-1"
|
|
ref2 := "JIRA-2"
|
|
issues := []*types.Issue{
|
|
{ID: "bd-1", Title: "Issue 1", ExternalRef: &ref1},
|
|
{ID: "bd-2", Title: "Issue 2", ExternalRef: &ref2},
|
|
}
|
|
err := validateNoDuplicateExternalRefs(issues)
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("duplicate external_ref values", func(t *testing.T) {
|
|
ref1 := "JIRA-1"
|
|
ref2 := "JIRA-1"
|
|
issues := []*types.Issue{
|
|
{ID: "bd-1", Title: "Issue 1", ExternalRef: &ref1},
|
|
{ID: "bd-2", Title: "Issue 2", ExternalRef: &ref2},
|
|
}
|
|
err := validateNoDuplicateExternalRefs(issues)
|
|
if err == nil {
|
|
t.Error("Expected error for duplicate external_ref, got nil")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "duplicate external_ref values") {
|
|
t.Errorf("Expected error about duplicates, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("multiple duplicates", func(t *testing.T) {
|
|
jira1 := "JIRA-1"
|
|
jira2 := "JIRA-2"
|
|
issues := []*types.Issue{
|
|
{ID: "bd-1", Title: "Issue 1", ExternalRef: &jira1},
|
|
{ID: "bd-2", Title: "Issue 2", ExternalRef: &jira1},
|
|
{ID: "bd-3", Title: "Issue 3", ExternalRef: &jira2},
|
|
{ID: "bd-4", Title: "Issue 4", ExternalRef: &jira2},
|
|
}
|
|
err := validateNoDuplicateExternalRefs(issues)
|
|
if err == nil {
|
|
t.Error("Expected error for duplicate external_ref, got nil")
|
|
}
|
|
if err != nil {
|
|
if !strings.Contains(err.Error(), "JIRA-1") || !strings.Contains(err.Error(), "JIRA-2") {
|
|
t.Errorf("Expected error to mention both JIRA-1 and JIRA-2, got: %v", err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("ignores empty external_ref", func(t *testing.T) {
|
|
empty := ""
|
|
ref1 := "JIRA-1"
|
|
issues := []*types.Issue{
|
|
{ID: "bd-1", Title: "Issue 1", ExternalRef: &empty},
|
|
{ID: "bd-2", Title: "Issue 2", ExternalRef: &empty},
|
|
{ID: "bd-3", Title: "Issue 3", ExternalRef: &ref1},
|
|
}
|
|
err := validateNoDuplicateExternalRefs(issues)
|
|
if err != nil {
|
|
t.Errorf("Expected no error for empty refs, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestConcurrentExternalRefImports(t *testing.T) {
|
|
t.Skip("TODO(bd-gpe7): Test hangs due to database deadlock - needs investigation")
|
|
|
|
t.Run("sequential imports with same external_ref are detected as updates", func(t *testing.T) {
|
|
store, err := sqlite.New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
externalRef := "JIRA-100"
|
|
|
|
issue1 := &types.Issue{
|
|
ID: "bd-1",
|
|
Title: "First import",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
ExternalRef: &externalRef,
|
|
}
|
|
|
|
result1, err := ImportIssues(ctx, "", store, []*types.Issue{issue1}, Options{})
|
|
if err != nil {
|
|
t.Fatalf("First import failed: %v", err)
|
|
}
|
|
|
|
if result1.Created != 1 {
|
|
t.Errorf("Expected 1 created, got %d", result1.Created)
|
|
}
|
|
|
|
issue2 := &types.Issue{
|
|
ID: "bd-2",
|
|
Title: "Second import (different ID, same external_ref)",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
ExternalRef: &externalRef,
|
|
UpdatedAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
|
|
result2, err := ImportIssues(ctx, "", store, []*types.Issue{issue2}, Options{})
|
|
if err != nil {
|
|
t.Fatalf("Second import failed: %v", err)
|
|
}
|
|
|
|
if result2.Updated != 1 {
|
|
t.Errorf("Expected 1 updated, got %d (created: %d)", result2.Updated, result2.Created)
|
|
}
|
|
|
|
finalIssue, err := store.GetIssueByExternalRef(ctx, externalRef)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get final issue: %v", err)
|
|
}
|
|
|
|
if finalIssue.ID != "bd-1" {
|
|
t.Errorf("Expected final issue ID to be bd-1, got %s", finalIssue.ID)
|
|
}
|
|
|
|
if finalIssue.Title != "Second import (different ID, same external_ref)" {
|
|
t.Errorf("Expected title to be updated, got %s", finalIssue.Title)
|
|
}
|
|
})
|
|
|
|
t.Run("concurrent updates to same external_ref with different timestamps", func(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping slow concurrent test in short mode")
|
|
}
|
|
|
|
store, err := sqlite.New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
externalRef := "JIRA-200"
|
|
existing := &types.Issue{
|
|
ID: "bd-1",
|
|
Title: "Existing issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
ExternalRef: &externalRef,
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, existing, "test"); err != nil {
|
|
t.Fatalf("Failed to create existing issue: %v", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]*Result, 3)
|
|
done := make(chan bool, 1)
|
|
|
|
for i := 0; i < 3; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
|
|
updated := &types.Issue{
|
|
ID: "bd-import-" + string(rune('1'+idx)),
|
|
Title: "Updated from worker " + string(rune('A'+idx)),
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
ExternalRef: &externalRef,
|
|
UpdatedAt: time.Now().Add(time.Duration(idx) * time.Second),
|
|
}
|
|
|
|
result, _ := ImportIssues(ctx, "", store, []*types.Issue{updated}, Options{})
|
|
results[idx] = result
|
|
}(i)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Test completed normally
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("Test timed out after 30 seconds - likely deadlock in concurrent imports")
|
|
}
|
|
|
|
finalIssue, err := store.GetIssueByExternalRef(ctx, externalRef)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get final issue: %v", err)
|
|
}
|
|
|
|
if finalIssue == nil {
|
|
t.Error("Expected final issue to exist")
|
|
}
|
|
|
|
t.Logf("Final issue title: %s, status: %s", finalIssue.Title, finalIssue.Status)
|
|
})
|
|
}
|