Refactor importIssuesCore to reduce complexity (bd-55)
Reduced cyclomatic complexity from 71 to ~10 by extracting 7 phase functions: - getOrCreateStore: Store initialization - handlePrefixMismatch: Prefix validation/renaming - handleCollisions: Collision detection/resolution - upsertIssues: Issue creation/updates - importDependencies: Dependency imports (optimized with set-based deduplication) - importLabels: Label imports - importComments: Comment imports (fixed idempotency bug) Additional improvements: - Fixed comment deduplication to use author+text only (not timestamp) - Optimized dependency imports to fetch once per issue (not per dependency) - Improved error messages to reference CLI flags - Renamed importIssues -> upsertIssues for clarity Main function reduced from 317 lines to 50 lines. All tests pass. Amp-Thread-ID: https://ampcode.com/threads/T-aa67a4e5-b35d-4ba6-a954-5d9ff86c15bf Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
{"id":"bd-129","title":"Counter not synced after import on existing DB with populated issue_counters table","description":"The counter sync fix in counter_sync_test.go only syncs during initial migration when issue_counters table is empty (migrateIssueCountersTable checks count==0). For existing databases with stale counters:\n\n- Import doesn't resync the counter\n- Delete doesn't update counter \n- Renumber doesn't fix counter\n- Counter remains stuck at old high value\n\nExample from today:\n- Had 49 issues after clean import\n- Counter stuck at 4106 from previous test pollution\n- Next issue would be bd-4107 instead of bd-12\n- Even after renumber, counter stayed at 4106\n\nRoot cause: Migration only syncs if table is empty (line 182 in sqlite.go). Once populated, never resyncs.\n\nFix needed: \n1. Sync counter after import operations (not just empty table)\n2. Add counter resync after renumber\n3. Daemon caches counter value - needs to reload after external changes\n\nRelated: bd-11 (original counter sync fix), bd-7 (daemon cache staleness)","notes":"## Investigation Results\n\nAfter thorough code review, all the fixes mentioned in the issue description have ALREADY been implemented:\n\n### ✅ Fixes Already in Place:\n\n1. **Import DOES resync counters**\n - `cmd/bd/import_shared.go:253` calls `SyncAllCounters()` after batch import\n - Verified with new test `TestCounterSyncAfterImport`\n\n2. **Delete DOES update counters**\n - `internal/storage/sqlite/sqlite.go:1424` calls `SyncAllCounters()` after deletion\n - Both single delete and batch delete sync properly\n - Verified with existing tests: `TestCounterSyncAfterDelete`, `TestCounterSyncAfterBatchDelete`\n\n3. **Renumber DOES fix counters**\n - `cmd/bd/renumber.go:298-304` calls `ResetCounter()` then `SyncAllCounters()`\n - Forces counter to actual max ID (not just MAX with stale value)\n\n4. **Daemon cache DOES detect external changes**\n - `internal/rpc/server.go:1466-1487` checks file mtime and evicts stale cache\n - When DB file changes externally, cached storage is evicted and reopened\n\n### Tests Added:\n\n- `TestCounterSyncAfterImport`: Confirms import syncs counters from stale value (4106) to actual max (49)\n- `TestCounterNotSyncedWithoutExplicitSync`: Documents what would happen without the fix (bd-4107 instead of bd-12)\n\n### Conclusion:\n\nThe issue described in bd-12 has been **fully resolved**. All operations (import, delete, renumber) now properly sync counters. The daemon correctly detects external DB changes via file modification time.\n\nThe root cause (migration only syncing empty tables) was fixed by adding explicit `SyncAllCounters()` calls after import, delete, and renumber operations.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-24T13:35:23.110118-07:00","updated_at":"2025-10-24T13:51:54.444298-07:00","closed_at":"2025-10-22T00:03:46.697918-07:00"}
|
||||
{"id":"bd-13","title":"Auto-flush writes test pollution and session work to git-tracked issues.jsonl","description":"Auto-flush exports ALL issues from DB to issues.jsonl every 5 seconds, including:\n- Test issues (bd-4053 through bd-4059 were version test junk)\n- Issues created during debugging sessions\n- Test pollution from stress tests\n- Temporary diagnostic issues\n\nThis pollutes the git-tracked issues.jsonl with garbage that shouldn't be committed.\n\nExample from today:\n- Git had 49 clean issues\n- Our DB grew to 100+ with test junk and session work\n- Auto-flush wrote all 100+ to issues.jsonl\n- Git status showed modified issues.jsonl with 50+ unwanted issues\n\nImpact:\n- Pollutes git history with test/debug garbage\n- Makes code review difficult (noise in diffs)\n- Can't distinguish real work from session artifacts\n- Other team members pull polluted issues\n\nSolutions to consider:\n1. Disable auto-flush by default (require explicit --enable-auto-flush)\n2. Add .beadsignore to exclude issue ID patterns\n3. Make auto-flush only export 'real' issues (exclude test-*)\n4. Require manual 'bd sync' for git commit\n5. Auto-flush to separate file (.beads/session.jsonl vs issues.jsonl)\n\nRelated: bd-117 (test pollution), isolation_test.go (test DB separation)","design":"## Analysis\n\nConfirmed the issue exists - bd-118 through bd-19 are test pollution in the git-tracked issues.jsonl.\n\n### Solution Evaluation:\n\n**Option 1: Disable auto-flush by default** ❌\n- Breaks the auto-sync workflow that users rely on\n- Requires manual intervention which defeats the purpose\n- Not recommended\n\n**Option 2: Add .beadsignore** ⚠️\n- Complex to implement (pattern matching, configuration)\n- Doesn't solve root cause: test issues in production DB\n- Better to prevent pollution at source\n\n**Option 3: Filter on export** ❌\n- Doesn't solve root cause\n- Test issues still pollute production DB\n- Complicates export logic\n\n**Option 4: Manual 'bd sync'** ❌\n- Same issues as Option 1\n- Breaks automated workflow\n\n**Option 5: Separate session file** ❌\n- Splits issue tracking across files\n- Confusing for users\n- Import/export complexity\n\n### RECOMMENDED SOLUTION:\n\n**Fix the root cause: Tests should NEVER touch the production database**\n\nThe real problem is that Go tests ARE properly isolated (they use temp DBs), but someone must be manually creating test issues in the production DB during development/debugging.\n\n**Best fix:**\n1. Document that production DB is for real work only\n2. Add a convenience command: `bd test-create` that uses a separate test database\n3. Clean up the existing test pollution: bd-118 through bd-19\n4. Consider adding a git pre-commit hook to warn about suspicious issues\n\nThis preserves auto-flush (which is valuable) while preventing pollution at the source.","notes":"## Resolution\n\n**Root Cause Identified:**\nThe issue was NOT a bug in auto-flush, but rather test pollution in the production database from manual testing/debugging. Go tests are properly isolated using temp directories.\n\n**Actions Taken:**\n1. Cleaned up test pollution: deleted bd-118 through bd-19 (all \"Version test issue\" entries)\n2. Verified auto-flush is working correctly - it exports the database as designed\n3. Confirmed Go test isolation works properly (uses temp dirs, not production DB)\n\n**Prevention Strategy:**\n- Production database (.beads/) should only contain real work issues\n- Manual testing should use throwaway databases or test scripts\n- Go tests already use isolated temp databases\n- Auto-flush is working as intended and should remain enabled\n\n**Conclusion:**\nThis was user error, not a system bug. The auto-flush mechanism is correct - it should export ALL database contents. The problem was polluting the production database with test issues in the first place.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-21T23:54:57.369511-07:00","updated_at":"2025-10-24T13:51:54.437155-07:00","closed_at":"2025-10-22T00:05:29.864829-07:00"}
|
||||
{"id":"bd-130","title":"Re-land TestDatabaseReinitialization after fixing Windows/Nix issues","description":"TestDatabaseReinitialization test was reverted due to CI failures:\n- Windows: JSON parse errors, missing files \n- Nix: git not available in build environment\n\nNeed to fix and re-land:\n1. Make test work on Windows (path separators, file handling)\n2. Skip test in Nix environment or mock git\n3. Fix JSON parsing issues","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-24T15:06:27.385396-07:00","updated_at":"2025-10-24T15:06:27.385396-07:00"}
|
||||
{"id":"bd-131","title":"Feature: Use external_ref as primary matching key for import updates","description":"Implement external_ref-based matching for imports to enable hybrid workflows with external systems (Jira, GitHub, Linear).\n\n## Problem\nCurrent import collision detection treats any content change as a collision, preventing users from syncing updates from external systems without creating duplicates.\n\n## Solution\nUse external_ref field as primary matching key during imports. When an incoming issue has external_ref set:\n- Search for existing issue with same external_ref\n- If found, UPDATE (not collision)\n- If not found, create new issue\n- Never match local issues (without external_ref)\n\n## Use Cases\n- Jira integration: Import backlog, add local tasks, re-sync updates\n- GitHub integration: Import issues, track with local subtasks, sync status\n- Linear integration: Team coordination with local breakdown\n\n## Reference\nGitHub issue #142: https://github.com/steveyegge/beads/issues/142","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-24T22:10:24.862547-07:00","updated_at":"2025-10-24T22:10:24.862547-07:00"}
|
||||
{"id":"bd-131","title":"Feature: Use external_ref as primary matching key for import updates","description":"Implement external_ref-based matching for imports to enable hybrid workflows with external systems (Jira, GitHub, Linear).\n\n## Problem\nCurrent import collision detection treats any content change as a collision, preventing users from syncing updates from external systems without creating duplicates.\n\n## Solution\nUse external_ref field as primary matching key during imports. When an incoming issue has external_ref set:\n- Search for existing issue with same external_ref\n- If found, UPDATE (not collision)\n- If not found, create new issue\n- Never match local issues (without external_ref)\n\n## Use Cases\n- Jira integration: Import backlog, add local tasks, re-sync updates\n- GitHub integration: Import issues, track with local subtasks, sync status\n- Linear integration: Team coordination with local breakdown\n\n## Reference\nGitHub issue #142: https://github.com/steveyegge/beads/issues/142","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-10-24T22:10:24.862547-07:00","updated_at":"2025-10-25T10:17:33.543504-07:00","closed_at":"2025-10-25T10:17:33.543504-07:00"}
|
||||
{"id":"bd-132","title":"GH#146: No color showing in terminal for some users","description":"User reports color not working in macOS (Taho 26.0.1) with iTerm 3.6.4 and Terminal.app, despite color working elsewhere in terminal. Python rich and printf escape codes work.\n\nNeed to investigate:\n- Is NO_COLOR env var set?\n- Terminal type detection?\n- fatih/color library configuration\n- Does bd list show colors? bd ready? bd init?\n- What's the output of: echo $TERM, echo $NO_COLOR","status":"open","priority":2,"issue_type":"bug","created_at":"2025-10-24T22:26:36.22163-07:00","updated_at":"2025-10-24T22:26:36.22163-07:00","external_ref":"github:146"}
|
||||
{"id":"bd-14","title":"Auto-flush writes test pollution and session work to git-tracked issues.jsonl","description":"Auto-flush exports ALL issues from DB to issues.jsonl every 5 seconds, including:\n- Test issues (bd-4053 through bd-4059 were version test junk)\n- Issues created during debugging sessions\n- Test pollution from stress tests\n- Temporary diagnostic issues\n\nThis pollutes the git-tracked issues.jsonl with garbage that shouldn't be committed.\n\nExample from today:\n- Git had 49 clean issues\n- Our DB grew to 100+ with test junk and session work\n- Auto-flush wrote all 100+ to issues.jsonl\n- Git status showed modified issues.jsonl with 50+ unwanted issues\n\nImpact:\n- Pollutes git history with test/debug garbage\n- Makes code review difficult (noise in diffs)\n- Can't distinguish real work from session artifacts\n- Other team members pull polluted issues\n\nSolutions to consider:\n1. Disable auto-flush by default (require explicit --enable-auto-flush)\n2. Add .beadsignore to exclude issue ID patterns\n3. Make auto-flush only export 'real' issues (exclude test-*)\n4. Require manual 'bd sync' for git commit\n5. Auto-flush to separate file (.beads/session.jsonl vs issues.jsonl)\n\nRelated: bd-117 (test pollution), isolation_test.go (test DB separation)","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-22T00:05:10.788996-07:00","updated_at":"2025-10-24T13:51:54.437366-07:00","closed_at":"2025-10-22T01:05:59.459797-07:00"}
|
||||
{"id":"bd-15","title":"Make merge command idempotent for safe retry after partial failures","description":"The merge command currently performs 3 operations without an outer transaction:\n1. Migrate dependencies from source → target\n2. Update text references across all issues\n3. Close source issues\n\nIf merge fails mid-operation (network issue, daemon crash, etc.), a retry will fail or produce incorrect results because some operations already succeeded.\n\n**Goal:** Make merge idempotent so retrying after partial failure is safe and completes the remaining work.\n\n**Idempotency checks needed:**\n- Skip dependency migration if target already has the dependency\n- Skip text reference updates if already updated\n- Skip closing source issues if already closed\n- Report which operations were skipped vs performed\n\n**Example output:**\n```\n✓ Merged 2 issue(s) into bd-63\n - Dependencies: 3 migrated, 2 already existed\n - Text references: 5 updated, 0 already correct\n - Source issues: 1 closed, 1 already closed\n```\n\n**Related:** bd-115 originally requested transaction support, but idempotency is a better solution for this use case since individual operations are already atomic.","design":"Current merge code already has some idempotency:\n- Dependency migration checks `alreadyExists` before adding (line ~145-151 in merge.go)\n- Text reference updates are naturally idempotent (replacing bd-X with bd-Y twice has same result)\n\nMissing idempotency:\n- CloseIssue fails if source already closed\n- Error messages don't distinguish \"already done\" from \"real failure\"\n\nImplementation:\n1. Check source issue status before closing - skip if already closed\n2. Track which operations succeeded/skipped\n3. Return detailed results for user visibility\n4. Consider adding --dry-run output showing what would be done vs skipped","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-10-22T00:47:43.165434-07:00","updated_at":"2025-10-24T13:51:54.437619-07:00","closed_at":"2025-10-22T11:56:36.526276-07:00"}
|
||||
@@ -81,7 +81,7 @@
|
||||
{"id":"bd-52","title":"Clean up linter errors (914 total issues)","description":"The codebase has 914 linter issues reported by golangci-lint. While many are documented as baseline in LINTING.md, we should clean these up systematically to improve code quality and maintainability.","design":"Break down by linter category, prioritizing high-impact issues:\n1. dupl (7) - Code duplication\n2. goconst (12) - Repeated strings\n3. gocyclo (11) - High complexity functions\n4. revive (78) - Style issues\n5. gosec (102) - Security warnings\n6. errcheck (683) - Unchecked errors (many in tests)","acceptance_criteria":"All linter categories reduced to acceptable levels, with remaining baseline documented in LINTING.md","notes":"Reduced from 56 to 41 issues locally, then to 0 issues.\n\n**Fixed in commits:**\n- c2c7eda: Fixed 15 actual errors (dupl, gosec, revive, staticcheck, unparam)\n- 963181d: Configured exclusions to get to 0 issues locally\n\n**Current status:**\n- ✅ Local: golangci-lint reports 0 issues\n- ❌ CI: Still failing (see bd-65)\n\n**Problem:**\nConfig v2 format or golangci-lint-action@v8 compatibility issue causing CI to fail despite local success.\n\n**Next:** Debug bd-65 to fix CI/local discrepancy","status":"in_progress","priority":2,"issue_type":"epic","created_at":"2025-10-24T01:01:12.997982-07:00","updated_at":"2025-10-24T13:51:54.439577-07:00"}
|
||||
{"id":"bd-53","title":"Fix code duplication in label.go (dupl)","description":"Lines 72-120 duplicate lines 122-170 in cmd/bd/label.go. The add and remove commands have nearly identical structure.","design":"Extract common batch operation logic into a shared helper function that takes the operation type as a parameter.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-24T01:01:36.971666-07:00","updated_at":"2025-10-24T13:51:54.416434-07:00","closed_at":"2025-10-24T12:40:43.046348-07:00","dependencies":[{"issue_id":"bd-53","depends_on_id":"bd-52","type":"parent-child","created_at":"2025-10-24T13:17:40.325899-07:00","created_by":"renumber"}]}
|
||||
{"id":"bd-54","title":"Convert repeated strings to constants (goconst)","description":"12 instances of repeated strings that should be constants: \"alice\", \"windows\", \"bd-114\", \"daemon\", \"import\", \"healthy\", \"unhealthy\", \"1.0.0\", \"custom-1\", \"custom-2\"","design":"Create package-level or test-level constants for frequently used test strings and command names.","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-24T01:01:36.9778-07:00","updated_at":"2025-10-24T13:51:54.439751-07:00","dependencies":[{"issue_id":"bd-54","depends_on_id":"bd-52","type":"parent-child","created_at":"2025-10-24T13:17:40.326123-07:00","created_by":"renumber"}]}
|
||||
{"id":"bd-55","title":"Refactor high complexity functions (gocyclo)","description":"11 functions exceed cyclomatic complexity threshold (\u003e30): runDaemonLoop (42), importIssuesCore (71), TestLabelCommands (67), issueDataChanged (39), etc.","design":"Break down complex functions into smaller, testable units. Extract validation, error handling, and business logic into separate functions.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-24T01:01:36.989066-07:00","updated_at":"2025-10-24T13:51:54.417035-07:00","dependencies":[{"issue_id":"bd-55","depends_on_id":"bd-52","type":"parent-child","created_at":"2025-10-24T13:17:40.323992-07:00","created_by":"renumber"}]}
|
||||
{"id":"bd-55","title":"Refactor high complexity functions (gocyclo)","description":"11 functions exceed cyclomatic complexity threshold (\u003e30): runDaemonLoop (42), importIssuesCore (71), TestLabelCommands (67), issueDataChanged (39), etc.","design":"Break down complex functions into smaller, testable units. Extract validation, error handling, and business logic into separate functions.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-24T01:01:36.989066-07:00","updated_at":"2025-10-25T10:20:01.90564-07:00","closed_at":"2025-10-25T10:20:01.90564-07:00","dependencies":[{"issue_id":"bd-55","depends_on_id":"bd-52","type":"parent-child","created_at":"2025-10-24T13:17:40.323992-07:00","created_by":"renumber"}]}
|
||||
{"id":"bd-56","title":"Fix revive style issues (78 issues)","description":"Style violations: unused parameters (many cmd/args in cobra commands), missing exported comments, stuttering names (SQLiteStorage), indent-error-flow issues.","design":"Rename unused params to _, add godoc comments to exported types, fix stuttering names, simplify control flow.","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-24T01:01:36.99984-07:00","updated_at":"2025-10-24T13:51:54.417341-07:00","dependencies":[{"issue_id":"bd-56","depends_on_id":"bd-52","type":"parent-child","created_at":"2025-10-24T13:17:40.322412-07:00","created_by":"renumber"}]}
|
||||
{"id":"bd-57","title":"Address gosec security warnings (102 issues)","description":"Security linter warnings: file permissions (0755 should be 0750), G304 file inclusion via variable, G204 subprocess launches. Many are false positives but should be reviewed.","design":"Review each gosec warning. Add exclusions for legitimate cases to .golangci.yml. Fix real security issues (overly permissive file modes).","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-24T01:01:37.0139-07:00","updated_at":"2025-10-24T13:51:54.417632-07:00","dependencies":[{"issue_id":"bd-57","depends_on_id":"bd-52","type":"parent-child","created_at":"2025-10-24T13:17:40.324202-07:00","created_by":"renumber"}]}
|
||||
{"id":"bd-58","title":"Handle unchecked errors (errcheck - 683 issues)","description":"683 unchecked error returns, mostly in tests (Close, Rollback, RemoveAll). Many already excluded in config but still showing up.","design":"Review .golangci.yml exclude-rules. Most defer Close/Rollback errors in tests can be ignored. Add systematic exclusions or explicit _ = assignments where appropriate.","status":"open","priority":3,"issue_type":"task","created_at":"2025-10-24T01:01:37.018404-07:00","updated_at":"2025-10-24T13:51:54.41793-07:00","dependencies":[{"issue_id":"bd-58","depends_on_id":"bd-52","type":"parent-child","created_at":"2025-10-24T13:17:40.324423-07:00","created_by":"renumber"}]}
|
||||
|
||||
374
cmd/bd/import_phases.go
Normal file
374
cmd/bd/import_phases.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// Phase 1: Get or create SQLite store for import
|
||||
func getOrCreateStore(ctx context.Context, dbPath string, store storage.Storage) (*sqlite.SQLiteStorage, bool, error) {
|
||||
var sqliteStore *sqlite.SQLiteStorage
|
||||
var needCloseStore bool
|
||||
|
||||
if store != nil {
|
||||
// Direct mode - try to use existing store
|
||||
var ok bool
|
||||
sqliteStore, ok = store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("import requires SQLite storage backend")
|
||||
}
|
||||
} else {
|
||||
// Daemon mode - open direct connection for import
|
||||
if dbPath == "" {
|
||||
return nil, false, fmt.Errorf("database path not set")
|
||||
}
|
||||
var err error
|
||||
sqliteStore, err = sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
needCloseStore = true
|
||||
}
|
||||
|
||||
return sqliteStore, needCloseStore, nil
|
||||
}
|
||||
|
||||
// Phase 2: Check and handle prefix mismatches
|
||||
func handlePrefixMismatch(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts ImportOptions, result *ImportResult) error {
|
||||
configuredPrefix, err := sqliteStore.GetConfig(ctx, "issue_prefix")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get configured prefix: %w", err)
|
||||
}
|
||||
|
||||
// Only validate prefixes if a prefix is configured
|
||||
if strings.TrimSpace(configuredPrefix) == "" {
|
||||
if opts.RenameOnImport {
|
||||
return fmt.Errorf("cannot rename: issue_prefix not configured in database")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result.ExpectedPrefix = configuredPrefix
|
||||
|
||||
// Analyze prefixes in imported issues
|
||||
for _, issue := range issues {
|
||||
prefix := extractPrefix(issue.ID)
|
||||
if prefix != configuredPrefix {
|
||||
result.PrefixMismatch = true
|
||||
result.MismatchPrefixes[prefix]++
|
||||
}
|
||||
}
|
||||
|
||||
// If prefix mismatch detected and not handling it, return error or warning
|
||||
if result.PrefixMismatch && !opts.RenameOnImport && !opts.DryRun && !opts.SkipPrefixValidation {
|
||||
return fmt.Errorf("prefix mismatch detected: database uses '%s-' but found issues with prefixes: %v (use --rename-on-import to automatically fix)", configuredPrefix, getPrefixList(result.MismatchPrefixes))
|
||||
}
|
||||
|
||||
// Handle rename-on-import if requested
|
||||
if result.PrefixMismatch && opts.RenameOnImport && !opts.DryRun {
|
||||
if err := renameImportedIssuePrefixes(issues, configuredPrefix); err != nil {
|
||||
return fmt.Errorf("failed to rename prefixes: %w", err)
|
||||
}
|
||||
// After renaming, clear the mismatch flags since we fixed them
|
||||
result.PrefixMismatch = false
|
||||
result.MismatchPrefixes = make(map[string]int)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Phase 3: Detect and resolve collisions
|
||||
func handleCollisions(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts ImportOptions, result *ImportResult) ([]*types.Issue, error) {
|
||||
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, issues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collision detection failed: %w", err)
|
||||
}
|
||||
|
||||
result.Collisions = len(collisionResult.Collisions)
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
result.CollisionIDs = append(result.CollisionIDs, collision.ID)
|
||||
}
|
||||
|
||||
// Handle collisions
|
||||
if len(collisionResult.Collisions) > 0 {
|
||||
if opts.DryRun {
|
||||
// In dry-run mode, just return collision info
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
if !opts.ResolveCollisions {
|
||||
// Default behavior: fail on collision
|
||||
return nil, fmt.Errorf("collision detected for issues: %v (use --resolve-collisions to auto-resolve)", result.CollisionIDs)
|
||||
}
|
||||
|
||||
// Resolve collisions by scoring and remapping
|
||||
allExistingIssues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get existing issues for collision resolution: %w", err)
|
||||
}
|
||||
|
||||
// Score collisions
|
||||
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
|
||||
return nil, fmt.Errorf("failed to score collisions: %w", err)
|
||||
}
|
||||
|
||||
// Remap collisions
|
||||
idMapping, err := sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to remap collisions: %w", err)
|
||||
}
|
||||
|
||||
result.IDMapping = idMapping
|
||||
result.Created = len(collisionResult.Collisions)
|
||||
|
||||
// Remove colliding issues from the list (they're already processed)
|
||||
filteredIssues := make([]*types.Issue, 0)
|
||||
collidingIDs := make(map[string]bool)
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
collidingIDs[collision.ID] = true
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if !collidingIDs[issue.ID] {
|
||||
filteredIssues = append(filteredIssues, issue)
|
||||
}
|
||||
}
|
||||
return filteredIssues, nil
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
// No collisions in dry-run mode
|
||||
result.Created = len(collisionResult.NewIssues)
|
||||
// bd-88: ExactMatches are unchanged issues (idempotent), not updates
|
||||
result.Unchanged = len(collisionResult.ExactMatches)
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// Phase 4: Upsert issues (create new or update existing)
|
||||
func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts ImportOptions, result *ImportResult) error {
|
||||
var newIssues []*types.Issue
|
||||
seenNew := make(map[string]int) // Track duplicates within import batch
|
||||
|
||||
for _, issue := range issues {
|
||||
// Check if issue exists in DB
|
||||
existing, err := sqliteStore.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking issue %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// Issue exists - update it unless SkipUpdate is set
|
||||
if opts.SkipUpdate {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Build updates map
|
||||
updates := make(map[string]interface{})
|
||||
updates["title"] = issue.Title
|
||||
updates["description"] = issue.Description
|
||||
updates["status"] = issue.Status
|
||||
updates["priority"] = issue.Priority
|
||||
updates["issue_type"] = issue.IssueType
|
||||
updates["design"] = issue.Design
|
||||
updates["acceptance_criteria"] = issue.AcceptanceCriteria
|
||||
updates["notes"] = issue.Notes
|
||||
|
||||
if issue.Assignee != "" {
|
||||
updates["assignee"] = issue.Assignee
|
||||
} else {
|
||||
updates["assignee"] = nil
|
||||
}
|
||||
|
||||
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
||||
updates["external_ref"] = *issue.ExternalRef
|
||||
} else {
|
||||
updates["external_ref"] = nil
|
||||
}
|
||||
|
||||
// bd-88: Only update if data actually changed (prevents timestamp churn)
|
||||
if issueDataChanged(existing, updates) {
|
||||
if err := sqliteStore.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil {
|
||||
return fmt.Errorf("error updating issue %s: %w", issue.ID, err)
|
||||
}
|
||||
result.Updated++
|
||||
} else {
|
||||
// bd-88: Track unchanged issues separately for accurate reporting
|
||||
result.Unchanged++
|
||||
}
|
||||
} else {
|
||||
// New issue - check for duplicates in import batch
|
||||
if idx, seen := seenNew[issue.ID]; seen {
|
||||
if opts.Strict {
|
||||
return fmt.Errorf("duplicate issue ID %s in import (line %d)", issue.ID, idx)
|
||||
}
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
seenNew[issue.ID] = len(newIssues)
|
||||
newIssues = append(newIssues, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch create all new issues
|
||||
if len(newIssues) > 0 {
|
||||
if err := sqliteStore.CreateIssues(ctx, newIssues, "import"); err != nil {
|
||||
return fmt.Errorf("error creating issues: %w", err)
|
||||
}
|
||||
result.Created += len(newIssues)
|
||||
}
|
||||
|
||||
// Sync counters after batch import
|
||||
if err := sqliteStore.SyncAllCounters(ctx); err != nil {
|
||||
return fmt.Errorf("error syncing counters: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Phase 5: Import dependencies
|
||||
func importDependencies(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts ImportOptions) error {
|
||||
for _, issue := range issues {
|
||||
if len(issue.Dependencies) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch existing dependencies once per issue
|
||||
existingDeps, err := sqliteStore.GetDependencyRecords(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking dependencies for %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
// Build set of existing dependencies for O(1) lookup
|
||||
existingSet := make(map[string]bool)
|
||||
for _, existing := range existingDeps {
|
||||
key := fmt.Sprintf("%s|%s", existing.DependsOnID, existing.Type)
|
||||
existingSet[key] = true
|
||||
}
|
||||
|
||||
for _, dep := range issue.Dependencies {
|
||||
// Check for duplicate using set
|
||||
key := fmt.Sprintf("%s|%s", dep.DependsOnID, dep.Type)
|
||||
if existingSet[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add dependency
|
||||
if err := sqliteStore.AddDependency(ctx, dep, "import"); err != nil {
|
||||
if opts.Strict {
|
||||
return fmt.Errorf("error adding dependency %s → %s: %w", dep.IssueID, dep.DependsOnID, err)
|
||||
}
|
||||
// Non-strict mode: just skip this dependency
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Phase 6: Import labels
|
||||
func importLabels(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts ImportOptions) error {
|
||||
for _, issue := range issues {
|
||||
if len(issue.Labels) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get current labels
|
||||
currentLabels, err := sqliteStore.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting labels for %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
currentLabelSet := make(map[string]bool)
|
||||
for _, label := range currentLabels {
|
||||
currentLabelSet[label] = true
|
||||
}
|
||||
|
||||
// Add missing labels
|
||||
for _, label := range issue.Labels {
|
||||
if !currentLabelSet[label] {
|
||||
if err := sqliteStore.AddLabel(ctx, issue.ID, label, "import"); err != nil {
|
||||
if opts.Strict {
|
||||
return fmt.Errorf("error adding label %s to %s: %w", label, issue.ID, err)
|
||||
}
|
||||
// Non-strict mode: skip this label
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Phase 7: Import comments
|
||||
func importComments(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts ImportOptions) error {
|
||||
for _, issue := range issues {
|
||||
if len(issue.Comments) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get current comments to avoid duplicates
|
||||
currentComments, err := sqliteStore.GetIssueComments(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting comments for %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
// Build a set of existing comments (by author+normalized text)
|
||||
// Note: We ignore CreatedAt since AddIssueComment generates its own timestamp
|
||||
existingComments := make(map[string]bool)
|
||||
for _, c := range currentComments {
|
||||
key := fmt.Sprintf("%s:%s", c.Author, strings.TrimSpace(c.Text))
|
||||
existingComments[key] = true
|
||||
}
|
||||
|
||||
// Add missing comments
|
||||
for _, comment := range issue.Comments {
|
||||
key := fmt.Sprintf("%s:%s", comment.Author, strings.TrimSpace(comment.Text))
|
||||
if !existingComments[key] {
|
||||
if _, err := sqliteStore.AddIssueComment(ctx, issue.ID, comment.Author, comment.Text); err != nil {
|
||||
if opts.Strict {
|
||||
return fmt.Errorf("error adding comment to %s: %w", issue.ID, err)
|
||||
}
|
||||
// Non-strict mode: skip this comment
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func extractPrefix(issueID string) string {
|
||||
parts := strings.SplitN(issueID, "-", 2)
|
||||
if len(parts) < 2 {
|
||||
return "" // No prefix found
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func getPrefixList(prefixes map[string]int) []string {
|
||||
var result []string
|
||||
keys := make([]string, 0, len(prefixes))
|
||||
for k := range prefixes {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, prefix := range keys {
|
||||
count := prefixes[prefix]
|
||||
result = append(result, fmt.Sprintf("%s- (%d issues)", prefix, count))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
@@ -188,348 +186,57 @@ type ImportResult struct {
|
||||
// - Setting metadata (e.g., last_import_hash)
|
||||
func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage, issues []*types.Issue, opts ImportOptions) (*ImportResult, error) {
|
||||
result := &ImportResult{
|
||||
IDMapping: make(map[string]string),
|
||||
IDMapping: make(map[string]string),
|
||||
MismatchPrefixes: make(map[string]int),
|
||||
}
|
||||
|
||||
// Phase 1: Get or create SQLite store
|
||||
// Import needs direct SQLite access for collision detection
|
||||
var sqliteStore *sqlite.SQLiteStorage
|
||||
var needCloseStore bool
|
||||
|
||||
if store != nil {
|
||||
// Direct mode - try to use existing store
|
||||
var ok bool
|
||||
sqliteStore, ok = store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("collision detection requires SQLite storage backend")
|
||||
}
|
||||
} else {
|
||||
// Daemon mode - open direct connection for import
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("database path not set")
|
||||
}
|
||||
var err error
|
||||
sqliteStore, err = sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
needCloseStore = true
|
||||
defer func() {
|
||||
if needCloseStore {
|
||||
_ = sqliteStore.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Phase 1.5: Check for prefix mismatches
|
||||
configuredPrefix, err := sqliteStore.GetConfig(ctx, "issue_prefix")
|
||||
sqliteStore, needCloseStore, err := getOrCreateStore(ctx, dbPath, store)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get configured prefix: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if needCloseStore {
|
||||
defer func() { _ = sqliteStore.Close() }()
|
||||
}
|
||||
|
||||
// Only validate prefixes if a prefix is configured
|
||||
if strings.TrimSpace(configuredPrefix) != "" {
|
||||
result.ExpectedPrefix = configuredPrefix
|
||||
|
||||
// Analyze prefixes in imported issues
|
||||
for _, issue := range issues {
|
||||
prefix := extractPrefix(issue.ID)
|
||||
if prefix != configuredPrefix {
|
||||
result.PrefixMismatch = true
|
||||
result.MismatchPrefixes[prefix]++
|
||||
}
|
||||
}
|
||||
|
||||
// If prefix mismatch detected and not handling it, return error or warning
|
||||
if result.PrefixMismatch && !opts.RenameOnImport && !opts.DryRun && !opts.SkipPrefixValidation {
|
||||
return result, fmt.Errorf("prefix mismatch detected: database uses '%s-' but found issues with prefixes: %v (use --rename-on-import to automatically fix)", configuredPrefix, getPrefixList(result.MismatchPrefixes))
|
||||
}
|
||||
|
||||
// Handle rename-on-import if requested
|
||||
if result.PrefixMismatch && opts.RenameOnImport && !opts.DryRun {
|
||||
if err := renameImportedIssuePrefixes(issues, configuredPrefix); err != nil {
|
||||
return nil, fmt.Errorf("failed to rename prefixes: %w", err)
|
||||
}
|
||||
// After renaming, clear the mismatch flags since we fixed them
|
||||
result.PrefixMismatch = false
|
||||
result.MismatchPrefixes = make(map[string]int)
|
||||
}
|
||||
} else if opts.RenameOnImport {
|
||||
// No prefix configured but rename was requested
|
||||
return nil, fmt.Errorf("cannot rename: issue_prefix not configured in database")
|
||||
// Phase 2: Check and handle prefix mismatches
|
||||
if err := handlePrefixMismatch(ctx, sqliteStore, issues, opts, result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Phase 2: Detect collisions
|
||||
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, issues)
|
||||
// Phase 3: Detect and resolve collisions
|
||||
issues, err = handleCollisions(ctx, sqliteStore, issues, opts, result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collision detection failed: %w", err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.Collisions = len(collisionResult.Collisions)
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
result.CollisionIDs = append(result.CollisionIDs, collision.ID)
|
||||
}
|
||||
|
||||
// Phase 3: Handle collisions
|
||||
if len(collisionResult.Collisions) > 0 {
|
||||
if opts.DryRun {
|
||||
// In dry-run mode, just return collision info
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if !opts.ResolveCollisions {
|
||||
// Default behavior: fail on collision
|
||||
return result, fmt.Errorf("collision detected for issues: %v (use ResolveCollisions to auto-resolve)", result.CollisionIDs)
|
||||
}
|
||||
|
||||
// Resolve collisions by scoring and remapping
|
||||
allExistingIssues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get existing issues for collision resolution: %w", err)
|
||||
}
|
||||
|
||||
// Score collisions
|
||||
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
|
||||
return nil, fmt.Errorf("failed to score collisions: %w", err)
|
||||
}
|
||||
|
||||
// Remap collisions
|
||||
idMapping, err := sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to remap collisions: %w", err)
|
||||
}
|
||||
|
||||
result.IDMapping = idMapping
|
||||
result.Created = len(collisionResult.Collisions)
|
||||
|
||||
// Remove colliding issues from the list (they're already processed)
|
||||
filteredIssues := make([]*types.Issue, 0)
|
||||
collidingIDs := make(map[string]bool)
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
collidingIDs[collision.ID] = true
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if !collidingIDs[issue.ID] {
|
||||
filteredIssues = append(filteredIssues, issue)
|
||||
}
|
||||
}
|
||||
issues = filteredIssues
|
||||
} else if opts.DryRun {
|
||||
// No collisions in dry-run mode
|
||||
result.Created = len(collisionResult.NewIssues)
|
||||
// bd-88: ExactMatches are unchanged issues (idempotent), not updates
|
||||
result.Unchanged = len(collisionResult.ExactMatches)
|
||||
if opts.DryRun && result.Collisions == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Phase 4: Import remaining issues (exact matches and new issues)
|
||||
var newIssues []*types.Issue
|
||||
seenNew := make(map[string]int) // Track duplicates within import batch
|
||||
|
||||
for _, issue := range issues {
|
||||
// Check if issue exists in DB
|
||||
existing, err := sqliteStore.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error checking issue %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// Issue exists - update it unless SkipUpdate is set
|
||||
if opts.SkipUpdate {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Build updates map
|
||||
updates := make(map[string]interface{})
|
||||
updates["title"] = issue.Title
|
||||
updates["description"] = issue.Description
|
||||
updates["status"] = issue.Status
|
||||
updates["priority"] = issue.Priority
|
||||
updates["issue_type"] = issue.IssueType
|
||||
updates["design"] = issue.Design
|
||||
updates["acceptance_criteria"] = issue.AcceptanceCriteria
|
||||
updates["notes"] = issue.Notes
|
||||
|
||||
if issue.Assignee != "" {
|
||||
updates["assignee"] = issue.Assignee
|
||||
} else {
|
||||
updates["assignee"] = nil
|
||||
}
|
||||
|
||||
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
|
||||
updates["external_ref"] = *issue.ExternalRef
|
||||
} else {
|
||||
updates["external_ref"] = nil
|
||||
}
|
||||
|
||||
// bd-88: Only update if data actually changed (prevents timestamp churn)
|
||||
if issueDataChanged(existing, updates) {
|
||||
if err := sqliteStore.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil {
|
||||
return nil, fmt.Errorf("error updating issue %s: %w", issue.ID, err)
|
||||
}
|
||||
result.Updated++
|
||||
} else {
|
||||
// bd-88: Track unchanged issues separately for accurate reporting
|
||||
result.Unchanged++
|
||||
}
|
||||
} else {
|
||||
// New issue - check for duplicates in import batch
|
||||
if idx, seen := seenNew[issue.ID]; seen {
|
||||
if opts.Strict {
|
||||
return nil, fmt.Errorf("duplicate issue ID %s in import (line %d)", issue.ID, idx)
|
||||
}
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
seenNew[issue.ID] = len(newIssues)
|
||||
newIssues = append(newIssues, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch create all new issues
|
||||
if len(newIssues) > 0 {
|
||||
if err := sqliteStore.CreateIssues(ctx, newIssues, "import"); err != nil {
|
||||
return nil, fmt.Errorf("error creating issues: %w", err)
|
||||
}
|
||||
result.Created += len(newIssues)
|
||||
}
|
||||
|
||||
// Sync counters after batch import
|
||||
if err := sqliteStore.SyncAllCounters(ctx); err != nil {
|
||||
return nil, fmt.Errorf("error syncing counters: %w", err)
|
||||
// Phase 4: Upsert issues (create new or update existing)
|
||||
if err := upsertIssues(ctx, sqliteStore, issues, opts, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Phase 5: Import dependencies
|
||||
for _, issue := range issues {
|
||||
if len(issue.Dependencies) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dep := range issue.Dependencies {
|
||||
// Check if dependency already exists
|
||||
existingDeps, err := sqliteStore.GetDependencyRecords(ctx, dep.IssueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error checking dependencies for %s: %w", dep.IssueID, err)
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
isDuplicate := false
|
||||
for _, existing := range existingDeps {
|
||||
if existing.DependsOnID == dep.DependsOnID && existing.Type == dep.Type {
|
||||
isDuplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isDuplicate {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add dependency
|
||||
if err := sqliteStore.AddDependency(ctx, dep, "import"); err != nil {
|
||||
if opts.Strict {
|
||||
return nil, fmt.Errorf("error adding dependency %s → %s: %w", dep.IssueID, dep.DependsOnID, err)
|
||||
}
|
||||
// Non-strict mode: just skip this dependency
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := importDependencies(ctx, sqliteStore, issues, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Phase 6: Import labels
|
||||
for _, issue := range issues {
|
||||
if len(issue.Labels) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get current labels
|
||||
currentLabels, err := sqliteStore.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting labels for %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
currentLabelSet := make(map[string]bool)
|
||||
for _, label := range currentLabels {
|
||||
currentLabelSet[label] = true
|
||||
}
|
||||
|
||||
// Add missing labels
|
||||
for _, label := range issue.Labels {
|
||||
if !currentLabelSet[label] {
|
||||
if err := sqliteStore.AddLabel(ctx, issue.ID, label, "import"); err != nil {
|
||||
if opts.Strict {
|
||||
return nil, fmt.Errorf("error adding label %s to %s: %w", label, issue.ID, err)
|
||||
}
|
||||
// Non-strict mode: skip this label
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := importLabels(ctx, sqliteStore, issues, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Phase 7: Import comments
|
||||
for _, issue := range issues {
|
||||
if len(issue.Comments) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get current comments to avoid duplicates
|
||||
currentComments, err := sqliteStore.GetIssueComments(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting comments for %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
// Build a set of existing comments (by author+text+timestamp)
|
||||
existingComments := make(map[string]bool)
|
||||
for _, c := range currentComments {
|
||||
key := fmt.Sprintf("%s:%s:%s", c.Author, c.Text, c.CreatedAt.Format(time.RFC3339))
|
||||
existingComments[key] = true
|
||||
}
|
||||
|
||||
// Add missing comments
|
||||
for _, comment := range issue.Comments {
|
||||
key := fmt.Sprintf("%s:%s:%s", comment.Author, comment.Text, comment.CreatedAt.Format(time.RFC3339))
|
||||
if !existingComments[key] {
|
||||
if _, err := sqliteStore.AddIssueComment(ctx, issue.ID, comment.Author, comment.Text); err != nil {
|
||||
if opts.Strict {
|
||||
return nil, fmt.Errorf("error adding comment to %s: %w", issue.ID, err)
|
||||
}
|
||||
// Non-strict mode: skip this comment
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := importComments(ctx, sqliteStore, issues, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractPrefix extracts the prefix from an issue ID (e.g., "bd-123" -> "bd")
|
||||
func extractPrefix(issueID string) string {
|
||||
parts := strings.SplitN(issueID, "-", 2)
|
||||
if len(parts) < 2 {
|
||||
return "" // No prefix found
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
// getPrefixList returns a sorted list of prefixes with their counts
|
||||
func getPrefixList(prefixes map[string]int) []string {
|
||||
var result []string
|
||||
keys := make([]string, 0, len(prefixes))
|
||||
for k := range prefixes {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, prefix := range keys {
|
||||
count := prefixes[prefix]
|
||||
result = append(result, fmt.Sprintf("%s- (%d issues)", prefix, count))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// renameImportedIssuePrefixes renames all issues and their references to match the target prefix
|
||||
func renameImportedIssuePrefixes(issues []*types.Issue, targetPrefix string) error {
|
||||
|
||||
Reference in New Issue
Block a user