Add --clear-duplicate-external-refs flag to bd import

Fixes GH-234 by providing automatic resolution for duplicate external_ref
values instead of forcing manual JSONL editing.

Changes:
- Add ClearDuplicateExternalRefs option to importer.Options
- Modify validateNoDuplicateExternalRefs to clear duplicates when enabled
- Keep first occurrence, clear rest when flag is set
- Enhanced error message to suggest the flag
- Add comprehensive tests for the new behavior

Usage: bd import -i issues.jsonl --clear-duplicate-external-refs
Amp-Thread-ID: https://ampcode.com/threads/T-932dcf45-76f2-4994-9b5c-a6eb20a86036
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-06 13:01:44 -08:00
parent b5e2ef4a59
commit 9de98cf1cb
4 changed files with 80 additions and 31 deletions

View File

@@ -29,12 +29,13 @@ const (
// Options contains import configuration
type Options struct {
DryRun bool // Preview changes without applying them
SkipUpdate bool // Skip updating existing issues (create-only mode)
Strict bool // Fail on any error (dependencies, labels, etc.)
RenameOnImport bool // Rename imported issues to match database prefix
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
OrphanHandling OrphanHandling // How to handle missing parent issues (default: allow)
DryRun bool // Preview changes without applying them
SkipUpdate bool // Skip updating existing issues (create-only mode)
Strict bool // Fail on any error (dependencies, labels, etc.)
RenameOnImport bool // Rename imported issues to match database prefix
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
OrphanHandling OrphanHandling // How to handle missing parent issues (default: allow)
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
}
// Result contains statistics about the import operation
@@ -109,7 +110,7 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
}
// Validate no duplicate external_ref values in batch
if err := validateNoDuplicateExternalRefs(issues); err != nil {
if err := validateNoDuplicateExternalRefs(issues, opts.ClearDuplicateExternalRefs, result); err != nil {
return result, err
}
@@ -741,7 +742,7 @@ func GetPrefixList(prefixes map[string]int) []string {
return result
}
func validateNoDuplicateExternalRefs(issues []*types.Issue) error {
func validateNoDuplicateExternalRefs(issues []*types.Issue, clearDuplicates bool, result *Result) error {
seen := make(map[string][]string)
for _, issue := range issues {
@@ -752,15 +753,34 @@ func validateNoDuplicateExternalRefs(issues []*types.Issue) error {
}
var duplicates []string
duplicateIssueIDs := make(map[string]bool)
for ref, issueIDs := range seen {
if len(issueIDs) > 1 {
duplicates = append(duplicates, fmt.Sprintf("external_ref '%s' appears in issues: %v", ref, issueIDs))
// Track all duplicate issue IDs except the first one (keep first, clear rest)
for i := 1; i < len(issueIDs); i++ {
duplicateIssueIDs[issueIDs[i]] = true
}
}
}
if len(duplicates) > 0 {
if clearDuplicates {
// Clear duplicate external_refs (keep first occurrence, clear rest)
for _, issue := range issues {
if duplicateIssueIDs[issue.ID] {
issue.ExternalRef = nil
}
}
// Track how many were cleared in result
if result != nil {
result.Skipped += len(duplicateIssueIDs)
}
return nil
}
sort.Strings(duplicates)
return fmt.Errorf("batch import contains duplicate external_ref values:\n%s", strings.Join(duplicates, "\n"))
return fmt.Errorf("batch import contains duplicate external_ref values:\n%s\n\nUse --clear-duplicate-external-refs to automatically clear duplicates", strings.Join(duplicates, "\n"))
}
return nil

View File

@@ -905,7 +905,7 @@ func TestValidateNoDuplicateExternalRefs(t *testing.T) {
{ID: "bd-1", Title: "Issue 1"},
{ID: "bd-2", Title: "Issue 2"},
}
err := validateNoDuplicateExternalRefs(issues)
err := validateNoDuplicateExternalRefs(issues, false, nil)
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
@@ -918,7 +918,7 @@ func TestValidateNoDuplicateExternalRefs(t *testing.T) {
{ID: "bd-1", Title: "Issue 1", ExternalRef: &ref1},
{ID: "bd-2", Title: "Issue 2", ExternalRef: &ref2},
}
err := validateNoDuplicateExternalRefs(issues)
err := validateNoDuplicateExternalRefs(issues, false, nil)
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
@@ -931,7 +931,7 @@ func TestValidateNoDuplicateExternalRefs(t *testing.T) {
{ID: "bd-1", Title: "Issue 1", ExternalRef: &ref1},
{ID: "bd-2", Title: "Issue 2", ExternalRef: &ref2},
}
err := validateNoDuplicateExternalRefs(issues)
err := validateNoDuplicateExternalRefs(issues, false, nil)
if err == nil {
t.Error("Expected error for duplicate external_ref, got nil")
}
@@ -940,6 +940,30 @@ func TestValidateNoDuplicateExternalRefs(t *testing.T) {
}
})
t.Run("duplicate external_ref values with clear flag", 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},
}
result := &Result{}
err := validateNoDuplicateExternalRefs(issues, true, result)
if err != nil {
t.Errorf("Expected no error with clear flag, got: %v", err)
}
// First issue should keep external_ref, second should be cleared
if issues[0].ExternalRef == nil || *issues[0].ExternalRef != "JIRA-1" {
t.Error("Expected first issue to keep external_ref JIRA-1")
}
if issues[1].ExternalRef != nil {
t.Error("Expected second issue to have cleared external_ref")
}
if result.Skipped != 1 {
t.Errorf("Expected 1 skipped (cleared), got %d", result.Skipped)
}
})
t.Run("multiple duplicates", func(t *testing.T) {
jira1 := "JIRA-1"
jira2 := "JIRA-2"
@@ -949,7 +973,7 @@ func TestValidateNoDuplicateExternalRefs(t *testing.T) {
{ID: "bd-3", Title: "Issue 3", ExternalRef: &jira2},
{ID: "bd-4", Title: "Issue 4", ExternalRef: &jira2},
}
err := validateNoDuplicateExternalRefs(issues)
err := validateNoDuplicateExternalRefs(issues, false, nil)
if err == nil {
t.Error("Expected error for duplicate external_ref, got nil")
}
@@ -968,7 +992,7 @@ func TestValidateNoDuplicateExternalRefs(t *testing.T) {
{ID: "bd-2", Title: "Issue 2", ExternalRef: &empty},
{ID: "bd-3", Title: "Issue 3", ExternalRef: &ref1},
}
err := validateNoDuplicateExternalRefs(issues)
err := validateNoDuplicateExternalRefs(issues, false, nil)
if err != nil {
t.Errorf("Expected no error for empty refs, got: %v", err)
}