Add external_ref UNIQUE constraint and validation
- Add migration for UNIQUE index on external_ref column (bd-897a) - Add validation for duplicate external_ref in batch imports (bd-7315) - Add query planner test to verify index usage (bd-f9a1) - Add concurrent import tests for external_ref (bd-3f6a) The migration detects existing duplicates and fails gracefully. Batch imports now reject duplicates with clear error messages. Tests verify the index is actually used by SQLite query planner. Amp-Thread-ID: https://ampcode.com/threads/T-45ca66ed-3912-46c4-963c-caa7724a9a2f Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -88,6 +88,11 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Validate no duplicate external_ref values in batch
|
||||
if err := validateNoDuplicateExternalRefs(issues); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Detect and resolve collisions
|
||||
issues, err = detectUpdates(ctx, sqliteStore, issues, opts, result)
|
||||
if err != nil {
|
||||
@@ -672,3 +677,28 @@ func GetPrefixList(prefixes map[string]int) []string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateNoDuplicateExternalRefs(issues []*types.Issue) error {
|
||||
seen := make(map[string][]string)
|
||||
|
||||
for _, issue := range issues {
|
||||
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
||||
ref := *issue.ExternalRef
|
||||
seen[ref] = append(seen[ref], issue.ID)
|
||||
}
|
||||
}
|
||||
|
||||
var duplicates []string
|
||||
for ref, issueIDs := range seen {
|
||||
if len(issueIDs) > 1 {
|
||||
duplicates = append(duplicates, fmt.Sprintf("external_ref '%s' appears in issues: %v", ref, issueIDs))
|
||||
}
|
||||
}
|
||||
|
||||
if len(duplicates) > 0 {
|
||||
sort.Strings(duplicates)
|
||||
return fmt.Errorf("batch import contains duplicate external_ref values:\n%s", strings.Join(duplicates, "\n"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -883,3 +885,209 @@ func TestGetPrefixList(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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.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) {
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user