fix(duplicates): use combined weight (dependents + dependencies) for merge target selection (GH#1022)

When choosing which duplicate to keep, the merge target now considers
both dependentCount (children/blocked-by) AND dependsOnCount (dependencies).
This ensures issues with ANY structural connections are preferred over
empty shells, rather than only considering children.

- Updated chooseMergeTarget to calculate weight = dependentCount + dependsOnCount
- Updated display output to show weight instead of just dependents
- Updated JSON output to include dependencies and weight fields
- Added tests for dependsOnCount inclusion and combined weight calculation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
lydia
2026-01-17 03:44:02 -08:00
committed by Steve Yegge
parent 251ded73be
commit feed888b57
2 changed files with 58 additions and 20 deletions

View File

@@ -128,16 +128,16 @@ Example:
fmt.Printf("%s Group %d: %s\n", ui.RenderAccent("━━"), i+1, group[0].Title)
for _, issue := range group {
refs := refCounts[issue.ID]
depCount := 0
weight := 0
if score, ok := structuralScores[issue.ID]; ok {
depCount = score.dependentCount
weight = score.dependentCount + score.dependsOnCount
}
marker := " "
if issue.ID == target.ID {
marker = ui.RenderPass("→ ")
}
fmt.Printf("%s%s (%s, P%d, %d dependents, %d refs)\n",
marker, issue.ID, issue.Status, issue.Priority, depCount, refs)
fmt.Printf("%s%s (%s, P%d, weight=%d, %d refs)\n",
marker, issue.ID, issue.Status, issue.Priority, weight, refs)
}
sources := make([]string, 0, len(group)-1)
for _, issue := range group {
@@ -259,7 +259,7 @@ func countStructuralRelationships(groups [][]*types.Issue) map[string]*issueScor
}
// chooseMergeTarget selects the best issue to merge into
// Priority order:
// 1. Highest dependent count (children, blocked-by relationships) - most connected issue wins
// 1. Highest structural weight (dependents + dependencies) - most connected issue wins
// 2. Highest text reference count (mentions in descriptions/notes)
// 3. Lexicographically smallest ID (stable tiebreaker)
func chooseMergeTarget(group []*types.Issue, refCounts map[string]int, structuralScores map[string]*issueScore) *types.Issue {
@@ -268,34 +268,36 @@ func chooseMergeTarget(group []*types.Issue, refCounts map[string]int, structura
}
getScore := func(id string) (int, int) {
depCount := 0
weight := 0
if score, ok := structuralScores[id]; ok {
depCount = score.dependentCount
// Weight = children/dependents + dependencies
// An issue with ANY structural connections should be preferred over an empty shell
weight = score.dependentCount + score.dependsOnCount
}
textRefs := refCounts[id]
return depCount, textRefs
return weight, textRefs
}
target := group[0]
targetDeps, targetRefs := getScore(target.ID)
targetWeight, targetRefs := getScore(target.ID)
for _, issue := range group[1:] {
issueDeps, issueRefs := getScore(issue.ID)
issueWeight, issueRefs := getScore(issue.ID)
// Compare by dependent count first (children/blocked-by)
if issueDeps > targetDeps {
// Compare by structural weight first (dependents + dependencies)
if issueWeight > targetWeight {
target = issue
targetDeps, targetRefs = issueDeps, issueRefs
targetWeight, targetRefs = issueWeight, issueRefs
continue
}
if issueDeps < targetDeps {
if issueWeight < targetWeight {
continue
}
// Equal dependent count - compare by text references
// Equal weight - compare by text references
if issueRefs > targetRefs {
target = issue
targetDeps, targetRefs = issueDeps, issueRefs
targetWeight, targetRefs = issueWeight, issueRefs
continue
}
if issueRefs < targetRefs {
@@ -305,7 +307,7 @@ func chooseMergeTarget(group []*types.Issue, refCounts map[string]int, structura
// Equal on both - use lexicographically smallest ID as tiebreaker
if issue.ID < target.ID {
target = issue
targetDeps, targetRefs = issueDeps, issueRefs
targetWeight, targetRefs = issueWeight, issueRefs
}
}
return target
@@ -317,9 +319,11 @@ func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int
target := chooseMergeTarget(group, refCounts, structuralScores)
issues := make([]map[string]interface{}, len(group))
for i, issue := range group {
depCount := 0
dependents := 0
dependencies := 0
if score, ok := structuralScores[issue.ID]; ok {
depCount = score.dependentCount
dependents = score.dependentCount
dependencies = score.dependsOnCount
}
issues[i] = map[string]interface{}{
"id": issue.ID,
@@ -327,7 +331,9 @@ func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int
"status": issue.Status,
"priority": issue.Priority,
"references": refCounts[issue.ID],
"dependents": depCount,
"dependents": dependents,
"dependencies": dependencies,
"weight": dependents + dependencies,
"is_merge_target": issue.ID == target.ID,
}
}

View File

@@ -163,6 +163,38 @@ func TestChooseMergeTarget(t *testing.T) {
},
wantID: "bd-2", // Dependents take priority
},
{
name: "dependsOnCount included in weight calculation (GH#1022)",
group: []*types.Issue{
{ID: "bd-1", Title: "Task"}, // Has dependencies (depends on others)
{ID: "bd-2", Title: "Task"}, // Empty shell
},
refCounts: map[string]int{
"bd-1": 0,
"bd-2": 0,
},
structuralScores: map[string]*issueScore{
"bd-1": {dependentCount: 0, dependsOnCount: 3, textRefs: 0}, // Depends on 3 other issues
"bd-2": {dependentCount: 0, dependsOnCount: 0, textRefs: 0}, // Empty shell
},
wantID: "bd-1", // Issue with dependencies should be kept over empty shell
},
{
name: "weight combines dependents and dependencies (GH#1022)",
group: []*types.Issue{
{ID: "bd-1", Title: "Task"}, // Has only dependents (children)
{ID: "bd-2", Title: "Task"}, // Has both dependents and dependencies
},
refCounts: map[string]int{
"bd-1": 0,
"bd-2": 0,
},
structuralScores: map[string]*issueScore{
"bd-1": {dependentCount: 5, dependsOnCount: 0, textRefs: 0}, // Weight = 5
"bd-2": {dependentCount: 3, dependsOnCount: 4, textRefs: 0}, // Weight = 7
},
wantID: "bd-2", // Higher combined weight wins
},
}
for _, tt := range tests {