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.`,
|
and database file. Optionally specify a custom issue prefix.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
prefix, _ := cmd.Flags().GetString("prefix")
|
prefix, _ := cmd.Flags().GetString("prefix")
|
||||||
|
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||||
|
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
// Auto-detect from directory name
|
// Auto-detect from directory name
|
||||||
cwd, err := os.Getwd()
|
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)
|
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()
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
cyan := color.New(color.FgCyan).SprintFunc()
|
cyan := color.New(color.FgCyan).SprintFunc()
|
||||||
|
|
||||||
@@ -79,5 +86,6 @@ and database file. Optionally specify a custom issue prefix.`,
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
initCmd.Flags().StringP("prefix", "p", "", "Issue prefix (default: current directory name)")
|
initCmd.Flags().StringP("prefix", "p", "", "Issue prefix (default: current directory name)")
|
||||||
|
initCmd.Flags().BoolP("quiet", "q", false, "Suppress output (quiet mode)")
|
||||||
rootCmd.AddCommand(initCmd)
|
rootCmd.AddCommand(initCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
100
cmd/bd/main.go
100
cmd/bd/main.go
@@ -277,52 +277,69 @@ func autoImportIfNewer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track colliding IDs (used later to skip their dependencies too)
|
// If collisions detected, auto-resolve them by remapping to new IDs
|
||||||
collidingIDs := make(map[string]bool)
|
|
||||||
|
|
||||||
// If collisions detected, warn user and skip colliding issues
|
|
||||||
if len(collisionResult.Collisions) > 0 {
|
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 {
|
for _, collision := range collisionResult.Collisions {
|
||||||
collidingIDs[collision.ID] = true
|
collidingIDs[collision.ID] = true
|
||||||
}
|
}
|
||||||
|
filteredIssues := make([]*types.Issue, 0)
|
||||||
// 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)
|
|
||||||
for _, issue := range allIssues {
|
for _, issue := range allIssues {
|
||||||
if !collidingIDs[issue.ID] {
|
if !collidingIDs[issue.ID] {
|
||||||
safeIssues = append(safeIssues, issue)
|
filteredIssues = append(filteredIssues, issue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
allIssues = safeIssues
|
allIssues = filteredIssues
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import non-colliding issues (exact matches + new issues)
|
// 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 {
|
for _, issue := range allIssues {
|
||||||
// Skip if this issue was filtered out due to collision
|
|
||||||
if collidingIDs[issue.ID] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issue.Dependencies) == 0 {
|
if len(issue.Dependencies) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,11 +466,10 @@ func updateDependencyReferences(ctx context.Context, s *SQLiteStorage, idMapping
|
|||||||
|
|
||||||
// Phase 2: Apply all collected changes
|
// Phase 2: Apply all collected changes
|
||||||
for _, update := range updates {
|
for _, update := range updates {
|
||||||
// Remove old dependency
|
// Remove old dependency - use RemoveDependencyIfExists which doesn't error on missing deps
|
||||||
if err := s.RemoveDependency(ctx, update.oldIssueID, update.oldDependsOnID, "import-remap"); err != nil {
|
if err := s.removeDependencyIfExists(ctx, update.oldIssueID, update.oldDependsOnID, "import-remap"); err != nil {
|
||||||
// If the dependency doesn't exist (e.g., already removed), that's okay
|
return fmt.Errorf("failed to remove old dependency %s -> %s: %w",
|
||||||
// This can happen if both IssueID and DependsOnID were remapped
|
update.oldIssueID, update.oldDependsOnID, err)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new dependency with updated IDs
|
// Add new dependency with updated IDs
|
||||||
|
|||||||
@@ -271,13 +271,65 @@ func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOn
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
result, err := tx.ExecContext(ctx, `
|
||||||
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
||||||
`, issueID, dependsOnID)
|
`, issueID, dependsOnID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove dependency: %w", err)
|
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, `
|
_, err = tx.ExecContext(ctx, `
|
||||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
|
|||||||
Reference in New Issue
Block a user