Implement auto-merge functionality for duplicates command
When --auto-merge is used, performMerge now automatically: 1. Closes source issues with "Duplicate of <target>" reason 2. Links each source to target with a "related" dependency Closes bd-hdt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -92,8 +92,8 @@ Example:
|
||||
|
||||
if autoMerge || dryRun {
|
||||
if !dryRun {
|
||||
// TODO(bd-hdt): Call performMerge when implemented
|
||||
fmt.Fprintf(os.Stderr, "Auto-merge not yet fully implemented. Use suggested commands instead.\n")
|
||||
result := performMerge(target.ID, sources)
|
||||
mergeResults = append(mergeResults, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,3 +259,50 @@ func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// performMerge executes the merge operation:
|
||||
// 1. Closes all source issues with a reason indicating they are duplicates
|
||||
// 2. Links each source to the target with a "related" dependency
|
||||
// Returns a map with the merge result for JSON output
|
||||
func performMerge(targetID string, sourceIDs []string) map[string]interface{} {
|
||||
ctx := rootCtx
|
||||
result := map[string]interface{}{
|
||||
"target": targetID,
|
||||
"sources": sourceIDs,
|
||||
"closed": []string{},
|
||||
"linked": []string{},
|
||||
"errors": []string{},
|
||||
}
|
||||
|
||||
closedIDs := []string{}
|
||||
linkedIDs := []string{}
|
||||
errors := []string{}
|
||||
|
||||
for _, sourceID := range sourceIDs {
|
||||
// Close the duplicate issue
|
||||
reason := fmt.Sprintf("Duplicate of %s", targetID)
|
||||
if err := store.CloseIssue(ctx, sourceID, reason, actor); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to close %s: %v", sourceID, err))
|
||||
continue
|
||||
}
|
||||
closedIDs = append(closedIDs, sourceID)
|
||||
|
||||
// Add dependency linking source to target
|
||||
dep := &types.Dependency{
|
||||
IssueID: sourceID,
|
||||
DependsOnID: targetID,
|
||||
Type: types.DependencyType("related"),
|
||||
}
|
||||
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to link %s to %s: %v", sourceID, targetID, err))
|
||||
continue
|
||||
}
|
||||
linkedIDs = append(linkedIDs, sourceID)
|
||||
}
|
||||
|
||||
result["closed"] = closedIDs
|
||||
result["linked"] = linkedIDs
|
||||
result["errors"] = errors
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -262,3 +262,107 @@ func TestDuplicatesIntegration(t *testing.T) {
|
||||
t.Errorf("Expected duplicate group to contain 2 'Fix authentication bug' issues, got %d", dupCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformMerge(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up global state needed by performMerge
|
||||
oldStore := store
|
||||
oldRootCtx := rootCtx
|
||||
oldActor := actor
|
||||
store = testStore
|
||||
rootCtx = ctx
|
||||
actor = "test-user"
|
||||
defer func() {
|
||||
store = oldStore
|
||||
rootCtx = oldRootCtx
|
||||
actor = oldActor
|
||||
}()
|
||||
|
||||
// Create duplicate issues
|
||||
target := &types.Issue{
|
||||
Title: "Main issue",
|
||||
Description: "This is the target",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
source1 := &types.Issue{
|
||||
Title: "Main issue",
|
||||
Description: "This is the target",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
source2 := &types.Issue{
|
||||
Title: "Main issue",
|
||||
Description: "This is the target",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
for _, issue := range []*types.Issue{target, source1, source2} {
|
||||
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("CreateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the merge
|
||||
result := performMerge(target.ID, []string{source1.ID, source2.ID})
|
||||
|
||||
// Verify result structure
|
||||
closedIDs := result["closed"].([]string)
|
||||
linkedIDs := result["linked"].([]string)
|
||||
errors := result["errors"].([]string)
|
||||
|
||||
if len(closedIDs) != 2 {
|
||||
t.Errorf("Expected 2 closed issues, got %d", len(closedIDs))
|
||||
}
|
||||
if len(linkedIDs) != 2 {
|
||||
t.Errorf("Expected 2 linked issues, got %d", len(linkedIDs))
|
||||
}
|
||||
if len(errors) != 0 {
|
||||
t.Errorf("Expected 0 errors, got %d: %v", len(errors), errors)
|
||||
}
|
||||
|
||||
// Verify source issues are closed
|
||||
for _, sourceID := range []string{source1.ID, source2.ID} {
|
||||
issue, err := testStore.GetIssue(ctx, sourceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue(%s) failed: %v", sourceID, err)
|
||||
}
|
||||
if issue.Status != types.StatusClosed {
|
||||
t.Errorf("Issue %s should be closed, got status %s", sourceID, issue.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify target is still open
|
||||
targetIssue, err := testStore.GetIssue(ctx, target.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue(%s) failed: %v", target.ID, err)
|
||||
}
|
||||
if targetIssue.Status != types.StatusOpen {
|
||||
t.Errorf("Target issue should still be open, got status %s", targetIssue.Status)
|
||||
}
|
||||
|
||||
// Verify dependencies were created (GetDependencies returns issues this depends on)
|
||||
for _, sourceID := range []string{source1.ID, source2.ID} {
|
||||
deps, err := testStore.GetDependencies(ctx, sourceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencies(%s) failed: %v", sourceID, err)
|
||||
}
|
||||
found := false
|
||||
for _, dep := range deps {
|
||||
if dep.ID == target.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected dependency from %s to %s", sourceID, target.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user