Fix critical bugs: bd-169, bd-28, bd-393
- bd-169: Add -q/--quiet flag to bd init command - bd-28: Improve error handling in RemoveDependency - Now checks RowsAffected and returns error if dependency doesn't exist - New removeDependencyIfExists() helper for collision remapping - bd-393: CRITICAL - Fix auto-import skipping collisions - Auto-import was LOSING work from other workers - Now automatically remaps collisions to new IDs - Calls RemapCollisions() instead of skipping All tests pass. Amp-Thread-ID: https://ampcode.com/threads/T-cba86837-28db-47ce-94eb-67fade82376a Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -19,6 +19,8 @@ var initCmd = &cobra.Command{
|
||||
and database file. Optionally specify a custom issue prefix.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
prefix, _ := cmd.Flags().GetString("prefix")
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
|
||||
if prefix == "" {
|
||||
// Auto-detect from directory name
|
||||
cwd, err := os.Getwd()
|
||||
@@ -66,6 +68,11 @@ and database file. Optionally specify a custom issue prefix.`,
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
|
||||
}
|
||||
|
||||
// Skip output if quiet mode
|
||||
if quiet {
|
||||
return
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
|
||||
@@ -79,5 +86,6 @@ and database file. Optionally specify a custom issue prefix.`,
|
||||
|
||||
func init() {
|
||||
initCmd.Flags().StringP("prefix", "p", "", "Issue prefix (default: current directory name)")
|
||||
initCmd.Flags().BoolP("quiet", "q", false, "Suppress output (quiet mode)")
|
||||
rootCmd.AddCommand(initCmd)
|
||||
}
|
||||
|
||||
100
cmd/bd/main.go
100
cmd/bd/main.go
@@ -277,52 +277,69 @@ func autoImportIfNewer() {
|
||||
return
|
||||
}
|
||||
|
||||
// Track colliding IDs (used later to skip their dependencies too)
|
||||
collidingIDs := make(map[string]bool)
|
||||
|
||||
// If collisions detected, warn user and skip colliding issues
|
||||
// If collisions detected, auto-resolve them by remapping to new IDs
|
||||
if len(collisionResult.Collisions) > 0 {
|
||||
// Get all existing issues for scoring
|
||||
allExistingIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Auto-import failed: error getting existing issues: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Score collisions
|
||||
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Auto-import failed: error scoring collisions: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Remap collisions
|
||||
idMapping, err := sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Auto-import failed: error remapping collisions: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Show concise notification
|
||||
maxShow := 10
|
||||
numRemapped := len(idMapping)
|
||||
if numRemapped < maxShow {
|
||||
maxShow = numRemapped
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nAuto-import: remapped %d colliding issue(s) to new IDs:\n", numRemapped)
|
||||
i := 0
|
||||
for oldID, newID := range idMapping {
|
||||
if i >= maxShow {
|
||||
break
|
||||
}
|
||||
// Find the collision detail to get title
|
||||
var title string
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
if collision.ID == oldID {
|
||||
title = collision.IncomingIssue.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " %s → %s (%s)\n", oldID, newID, title)
|
||||
i++
|
||||
}
|
||||
if numRemapped > maxShow {
|
||||
fmt.Fprintf(os.Stderr, " ... and %d more\n", numRemapped-maxShow)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
|
||||
// Remove colliding issues from allIssues (they were already created with new IDs by RemapCollisions)
|
||||
collidingIDs := make(map[string]bool)
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
collidingIDs[collision.ID] = true
|
||||
}
|
||||
|
||||
// Concise warning: show first 10 collisions
|
||||
maxShow := 10
|
||||
if len(collisionResult.Collisions) < maxShow {
|
||||
maxShow = len(collisionResult.Collisions)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nAuto-import: skipped %d issue(s) due to local edits.\n", len(collisionResult.Collisions))
|
||||
fmt.Fprintf(os.Stderr, "Conflicting issues (showing first %d): ", maxShow)
|
||||
for i := 0; i < maxShow; i++ {
|
||||
if i > 0 {
|
||||
fmt.Fprintf(os.Stderr, ", ")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s", collisionResult.Collisions[i].ID)
|
||||
}
|
||||
if len(collisionResult.Collisions) > maxShow {
|
||||
fmt.Fprintf(os.Stderr, " ... and %d more", len(collisionResult.Collisions)-maxShow)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
fmt.Fprintf(os.Stderr, "To merge these changes, run: bd import -i %s --resolve-collisions\n\n", jsonlPath)
|
||||
|
||||
// Full details under BD_DEBUG
|
||||
if os.Getenv("BD_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "Debug: Full collision details:\n")
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
fmt.Fprintf(os.Stderr, " %s: %s\n", collision.ID, collision.IncomingIssue.Title)
|
||||
fmt.Fprintf(os.Stderr, " Conflicting fields: %v\n", collision.ConflictingFields)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove colliding issues from the import list
|
||||
safeIssues := make([]*types.Issue, 0)
|
||||
filteredIssues := make([]*types.Issue, 0)
|
||||
for _, issue := range allIssues {
|
||||
if !collidingIDs[issue.ID] {
|
||||
safeIssues = append(safeIssues, issue)
|
||||
filteredIssues = append(filteredIssues, issue)
|
||||
}
|
||||
}
|
||||
allIssues = safeIssues
|
||||
allIssues = filteredIssues
|
||||
}
|
||||
|
||||
// Import non-colliding issues (exact matches + new issues)
|
||||
@@ -381,13 +398,8 @@ func autoImportIfNewer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Import dependencies (skip colliding issues to maintain consistency)
|
||||
// Import dependencies
|
||||
for _, issue := range allIssues {
|
||||
// Skip if this issue was filtered out due to collision
|
||||
if collidingIDs[issue.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(issue.Dependencies) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -466,11 +466,10 @@ func updateDependencyReferences(ctx context.Context, s *SQLiteStorage, idMapping
|
||||
|
||||
// Phase 2: Apply all collected changes
|
||||
for _, update := range updates {
|
||||
// Remove old dependency
|
||||
if err := s.RemoveDependency(ctx, update.oldIssueID, update.oldDependsOnID, "import-remap"); err != nil {
|
||||
// If the dependency doesn't exist (e.g., already removed), that's okay
|
||||
// This can happen if both IssueID and DependsOnID were remapped
|
||||
continue
|
||||
// Remove old dependency - use RemoveDependencyIfExists which doesn't error on missing deps
|
||||
if err := s.removeDependencyIfExists(ctx, update.oldIssueID, update.oldDependsOnID, "import-remap"); err != nil {
|
||||
return fmt.Errorf("failed to remove old dependency %s -> %s: %w",
|
||||
update.oldIssueID, update.oldDependsOnID, err)
|
||||
}
|
||||
|
||||
// Add new dependency with updated IDs
|
||||
|
||||
@@ -271,13 +271,65 @@ func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOn
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
||||
`, issueID, dependsOnID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove dependency: %w", err)
|
||||
}
|
||||
|
||||
// Check if dependency existed
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("dependency from %s to %s does not exist", issueID, dependsOnID)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, issueID, types.EventDependencyRemoved, actor,
|
||||
fmt.Sprintf("Removed dependency on %s", dependsOnID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
// Mark both issues as dirty for incremental export
|
||||
if err := markIssuesDirtyTx(ctx, tx, []string{issueID, dependsOnID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// removeDependencyIfExists removes a dependency, returning nil if it doesn't exist
|
||||
// This is useful during remapping where dependencies may have been already removed
|
||||
func (s *SQLiteStorage) removeDependencyIfExists(ctx context.Context, issueID, dependsOnID string, actor string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
||||
`, issueID, dependsOnID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove dependency: %w", err)
|
||||
}
|
||||
|
||||
// Check if dependency existed - if not, that's okay, just skip the event
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
// Dependency didn't exist, nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
|
||||
Reference in New Issue
Block a user