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:
Steve Yegge
2025-11-27 22:39:01 -08:00
parent f06f282d26
commit 1675275e1c
3 changed files with 218 additions and 67 deletions

View File

@@ -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
}

View File

@@ -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)
}
}
}