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:
42
CONFIG.md
42
CONFIG.md
@@ -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)
|
- `max_collision_prob` - Maximum collision probability for adaptive hash IDs (default: 0.25)
|
||||||
- `min_hash_length` - Minimum hash ID length (default: 4)
|
- `min_hash_length` - Minimum hash ID length (default: 4)
|
||||||
- `max_hash_length` - Maximum hash ID length (default: 8)
|
- `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
|
### 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.
|
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
|
### Example: Jira Integration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Behavior:
|
|||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
renameOnImport, _ := cmd.Flags().GetBool("rename-on-import")
|
renameOnImport, _ := cmd.Flags().GetBool("rename-on-import")
|
||||||
dedupeAfter, _ := cmd.Flags().GetBool("dedupe-after")
|
dedupeAfter, _ := cmd.Flags().GetBool("dedupe-after")
|
||||||
|
orphanHandling, _ := cmd.Flags().GetString("orphan-handling")
|
||||||
|
|
||||||
// Open input
|
// Open input
|
||||||
in := os.Stdin
|
in := os.Stdin
|
||||||
@@ -98,6 +99,7 @@ Behavior:
|
|||||||
SkipUpdate: skipUpdate,
|
SkipUpdate: skipUpdate,
|
||||||
Strict: strict,
|
Strict: strict,
|
||||||
RenameOnImport: renameOnImport,
|
RenameOnImport: renameOnImport,
|
||||||
|
OrphanHandling: orphanHandling,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
|
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("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("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().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")
|
importCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output import statistics in JSON format")
|
||||||
rootCmd.AddCommand(importCmd)
|
rootCmd.AddCommand(importCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,11 +158,12 @@ func issueDataChanged(existing *types.Issue, updates map[string]interface{}) boo
|
|||||||
|
|
||||||
// ImportOptions configures how the import behaves
|
// ImportOptions configures how the import behaves
|
||||||
type ImportOptions struct {
|
type ImportOptions struct {
|
||||||
DryRun bool // Preview changes without applying them
|
DryRun bool // Preview changes without applying them
|
||||||
SkipUpdate bool // Skip updating existing issues (create-only mode)
|
SkipUpdate bool // Skip updating existing issues (create-only mode)
|
||||||
Strict bool // Fail on any error (dependencies, labels, etc.)
|
Strict bool // Fail on any error (dependencies, labels, etc.)
|
||||||
RenameOnImport bool // Rename imported issues to match database prefix
|
RenameOnImport bool // Rename imported issues to match database prefix
|
||||||
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
|
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
|
// ImportResult contains statistics about the import operation
|
||||||
@@ -198,6 +199,7 @@ func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage,
|
|||||||
Strict: opts.Strict,
|
Strict: opts.Strict,
|
||||||
RenameOnImport: opts.RenameOnImport,
|
RenameOnImport: opts.RenameOnImport,
|
||||||
SkipPrefixValidation: opts.SkipPrefixValidation,
|
SkipPrefixValidation: opts.SkipPrefixValidation,
|
||||||
|
OrphanHandling: importer.OrphanHandling(opts.OrphanHandling),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate to the importer package
|
// Delegate to the importer package
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read orphan handling from config if not explicitly set
|
||||||
|
if opts.OrphanHandling == "" {
|
||||||
|
opts.OrphanHandling = sqliteStore.GetOrphanHandling(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Check and handle prefix mismatches
|
// Check and handle prefix mismatches
|
||||||
if err := handlePrefixMismatch(ctx, sqliteStore, issues, opts, result); err != nil {
|
if err := handlePrefixMismatch(ctx, sqliteStore, issues, opts, result); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
|
|||||||
@@ -1208,6 +1208,22 @@ func (s *SQLiteStorage) DeleteConfig(ctx context.Context, key string) error {
|
|||||||
return err
|
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)
|
// SetMetadata sets a metadata value (for internal state like import hashes)
|
||||||
func (s *SQLiteStorage) SetMetadata(ctx context.Context, key, value string) error {
|
func (s *SQLiteStorage) SetMetadata(ctx context.Context, key, value string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
|||||||
Reference in New Issue
Block a user