feat(import): add import.orphan_handling config with 4 modes

- Add GetOrphanHandling() helper to SQLiteStorage (reads from config table)
- Add --orphan-handling flag to 'bd import' command
- Wire OrphanHandling through ImportOptions -> importer.Options
- Auto-read config if flag not provided (default: 'allow')
- Document in CONFIG.md with detailed mode explanations

Modes:
- strict: Fail on missing parent (safest)
- resurrect: Auto-create parent tombstones from JSONL
- skip: Skip orphans with warning
- allow: Import without validation (default, most permissive)

Closes bd-8072, bd-b92a

Amp-Thread-ID: https://ampcode.com/threads/T-fd18d4a5-06b3-4400-9073-194d570846d8
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-04 23:59:50 -08:00
parent 0bf5c91cb3
commit ff8f6ecadf
5 changed files with 72 additions and 4 deletions

View File

@@ -169,6 +169,7 @@ Configuration keys use dot-notation namespaces to organize settings:
- `max_collision_prob` - Maximum collision probability for adaptive hash IDs (default: 0.25)
- `min_hash_length` - Minimum hash ID length (default: 4)
- `max_hash_length` - Maximum hash ID length (default: 8)
- `import.orphan_handling` - How to handle hierarchical issues with missing parents during import (default: `allow`)
### Integration Namespaces
@@ -199,6 +200,47 @@ bd config set min_hash_length "5"
See [docs/ADAPTIVE_IDS.md](docs/ADAPTIVE_IDS.md) for detailed documentation.
### Example: Import Orphan Handling
Controls how imports handle hierarchical child issues when their parent is missing from the database:
```bash
# Strictest: Fail import if parent is missing (safest, prevents orphans)
bd config set import.orphan_handling "strict"
# Auto-resurrect: Search JSONL history and recreate missing parents as tombstones
bd config set import.orphan_handling "resurrect"
# Skip: Skip orphaned issues with warning (partial import)
bd config set import.orphan_handling "skip"
# Allow: Import orphans without validation (default, most permissive)
bd config set import.orphan_handling "allow"
```
**Mode details:**
- **`strict`** - Import fails immediately if a child's parent is missing. Use when database integrity is critical.
- **`resurrect`** - Searches the full JSONL file for missing parents and recreates them as tombstones (Status=Closed, Priority=4). Preserves hierarchy with minimal data. Dependencies are also resurrected on best-effort basis.
- **`skip`** - Skips orphaned children with a warning. Partial import succeeds but some issues are excluded.
- **`allow`** - Imports orphans without parent validation. Most permissive, works around import bugs. **This is the default** because it ensures all data is imported even if hierarchy is temporarily broken.
**Override per command:**
```bash
# Override config for a single import
bd import -i issues.jsonl --orphan-handling strict
# Auto-import (sync) uses config value
bd sync # Respects import.orphan_handling setting
```
**When to use each mode:**
- Use `allow` (default) for daily imports and auto-sync - ensures no data loss
- Use `resurrect` when importing from another database that had parent deletions
- Use `strict` only for controlled imports where you need to guarantee parent existence
- Use `skip` rarely - only when you want to selectively import a subset
### Example: Jira Integration
```bash

View File

@@ -33,6 +33,7 @@ Behavior:
dryRun, _ := cmd.Flags().GetBool("dry-run")
renameOnImport, _ := cmd.Flags().GetBool("rename-on-import")
dedupeAfter, _ := cmd.Flags().GetBool("dedupe-after")
orphanHandling, _ := cmd.Flags().GetString("orphan-handling")
// Open input
in := os.Stdin
@@ -98,6 +99,7 @@ Behavior:
SkipUpdate: skipUpdate,
Strict: strict,
RenameOnImport: renameOnImport,
OrphanHandling: orphanHandling,
}
result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
@@ -263,6 +265,7 @@ func init() {
importCmd.Flags().Bool("dedupe-after", false, "Detect and report content duplicates after import")
importCmd.Flags().Bool("dry-run", false, "Preview collision detection without making changes")
importCmd.Flags().Bool("rename-on-import", false, "Rename imported issues to match database prefix (updates all references)")
importCmd.Flags().String("orphan-handling", "", "How to handle missing parent issues: strict/resurrect/skip/allow (default: use config or 'allow')")
importCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output import statistics in JSON format")
rootCmd.AddCommand(importCmd)
}

View File

@@ -158,11 +158,12 @@ func issueDataChanged(existing *types.Issue, updates map[string]interface{}) boo
// ImportOptions configures how the import behaves
type ImportOptions 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
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 string // Orphan handling mode: strict/resurrect/skip/allow (empty = use config)
}
// ImportResult contains statistics about the import operation
@@ -198,6 +199,7 @@ func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage,
Strict: opts.Strict,
RenameOnImport: opts.RenameOnImport,
SkipPrefixValidation: opts.SkipPrefixValidation,
OrphanHandling: importer.OrphanHandling(opts.OrphanHandling),
}
// Delegate to the importer package

View File

@@ -97,6 +97,11 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
fmt.Fprintf(os.Stderr, "Warning: failed to clear export_hashes before import: %v\n", err)
}
}
// Read orphan handling from config if not explicitly set
if opts.OrphanHandling == "" {
opts.OrphanHandling = sqliteStore.GetOrphanHandling(ctx)
}
// Check and handle prefix mismatches
if err := handlePrefixMismatch(ctx, sqliteStore, issues, opts, result); err != nil {

View File

@@ -1208,6 +1208,22 @@ func (s *SQLiteStorage) DeleteConfig(ctx context.Context, key string) error {
return err
}
// GetOrphanHandling gets the import.orphan_handling config value
// Returns OrphanAllow (the default) if not set or if value is invalid
func (s *SQLiteStorage) GetOrphanHandling(ctx context.Context) OrphanHandling {
value, err := s.GetConfig(ctx, "import.orphan_handling")
if err != nil || value == "" {
return OrphanAllow // Default
}
switch OrphanHandling(value) {
case OrphanStrict, OrphanResurrect, OrphanSkip, OrphanAllow:
return OrphanHandling(value)
default:
return OrphanAllow // Invalid value, use default
}
}
// SetMetadata sets a metadata value (for internal state like import hashes)
func (s *SQLiteStorage) SetMetadata(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, `