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:
@@ -128,16 +128,16 @@ Example:
|
|||||||
fmt.Printf("%s Group %d: %s\n", ui.RenderAccent("━━"), i+1, group[0].Title)
|
fmt.Printf("%s Group %d: %s\n", ui.RenderAccent("━━"), i+1, group[0].Title)
|
||||||
for _, issue := range group {
|
for _, issue := range group {
|
||||||
refs := refCounts[issue.ID]
|
refs := refCounts[issue.ID]
|
||||||
depCount := 0
|
weight := 0
|
||||||
if score, ok := structuralScores[issue.ID]; ok {
|
if score, ok := structuralScores[issue.ID]; ok {
|
||||||
depCount = score.dependentCount
|
weight = score.dependentCount + score.dependsOnCount
|
||||||
}
|
}
|
||||||
marker := " "
|
marker := " "
|
||||||
if issue.ID == target.ID {
|
if issue.ID == target.ID {
|
||||||
marker = ui.RenderPass("→ ")
|
marker = ui.RenderPass("→ ")
|
||||||
}
|
}
|
||||||
fmt.Printf("%s%s (%s, P%d, %d dependents, %d refs)\n",
|
fmt.Printf("%s%s (%s, P%d, weight=%d, %d refs)\n",
|
||||||
marker, issue.ID, issue.Status, issue.Priority, depCount, refs)
|
marker, issue.ID, issue.Status, issue.Priority, weight, refs)
|
||||||
}
|
}
|
||||||
sources := make([]string, 0, len(group)-1)
|
sources := make([]string, 0, len(group)-1)
|
||||||
for _, issue := range group {
|
for _, issue := range group {
|
||||||
@@ -259,7 +259,7 @@ func countStructuralRelationships(groups [][]*types.Issue) map[string]*issueScor
|
|||||||
}
|
}
|
||||||
// chooseMergeTarget selects the best issue to merge into
|
// chooseMergeTarget selects the best issue to merge into
|
||||||
// Priority order:
|
// 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)
|
// 2. Highest text reference count (mentions in descriptions/notes)
|
||||||
// 3. Lexicographically smallest ID (stable tiebreaker)
|
// 3. Lexicographically smallest ID (stable tiebreaker)
|
||||||
func chooseMergeTarget(group []*types.Issue, refCounts map[string]int, structuralScores map[string]*issueScore) *types.Issue {
|
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) {
|
getScore := func(id string) (int, int) {
|
||||||
depCount := 0
|
weight := 0
|
||||||
if score, ok := structuralScores[id]; ok {
|
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]
|
textRefs := refCounts[id]
|
||||||
return depCount, textRefs
|
return weight, textRefs
|
||||||
}
|
}
|
||||||
|
|
||||||
target := group[0]
|
target := group[0]
|
||||||
targetDeps, targetRefs := getScore(target.ID)
|
targetWeight, targetRefs := getScore(target.ID)
|
||||||
|
|
||||||
for _, issue := range group[1:] {
|
for _, issue := range group[1:] {
|
||||||
issueDeps, issueRefs := getScore(issue.ID)
|
issueWeight, issueRefs := getScore(issue.ID)
|
||||||
|
|
||||||
// Compare by dependent count first (children/blocked-by)
|
// Compare by structural weight first (dependents + dependencies)
|
||||||
if issueDeps > targetDeps {
|
if issueWeight > targetWeight {
|
||||||
target = issue
|
target = issue
|
||||||
targetDeps, targetRefs = issueDeps, issueRefs
|
targetWeight, targetRefs = issueWeight, issueRefs
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if issueDeps < targetDeps {
|
if issueWeight < targetWeight {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Equal dependent count - compare by text references
|
// Equal weight - compare by text references
|
||||||
if issueRefs > targetRefs {
|
if issueRefs > targetRefs {
|
||||||
target = issue
|
target = issue
|
||||||
targetDeps, targetRefs = issueDeps, issueRefs
|
targetWeight, targetRefs = issueWeight, issueRefs
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if issueRefs < targetRefs {
|
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
|
// Equal on both - use lexicographically smallest ID as tiebreaker
|
||||||
if issue.ID < target.ID {
|
if issue.ID < target.ID {
|
||||||
target = issue
|
target = issue
|
||||||
targetDeps, targetRefs = issueDeps, issueRefs
|
targetWeight, targetRefs = issueWeight, issueRefs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return target
|
return target
|
||||||
@@ -317,9 +319,11 @@ func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int
|
|||||||
target := chooseMergeTarget(group, refCounts, structuralScores)
|
target := chooseMergeTarget(group, refCounts, structuralScores)
|
||||||
issues := make([]map[string]interface{}, len(group))
|
issues := make([]map[string]interface{}, len(group))
|
||||||
for i, issue := range group {
|
for i, issue := range group {
|
||||||
depCount := 0
|
dependents := 0
|
||||||
|
dependencies := 0
|
||||||
if score, ok := structuralScores[issue.ID]; ok {
|
if score, ok := structuralScores[issue.ID]; ok {
|
||||||
depCount = score.dependentCount
|
dependents = score.dependentCount
|
||||||
|
dependencies = score.dependsOnCount
|
||||||
}
|
}
|
||||||
issues[i] = map[string]interface{}{
|
issues[i] = map[string]interface{}{
|
||||||
"id": issue.ID,
|
"id": issue.ID,
|
||||||
@@ -327,7 +331,9 @@ func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int
|
|||||||
"status": issue.Status,
|
"status": issue.Status,
|
||||||
"priority": issue.Priority,
|
"priority": issue.Priority,
|
||||||
"references": refCounts[issue.ID],
|
"references": refCounts[issue.ID],
|
||||||
"dependents": depCount,
|
"dependents": dependents,
|
||||||
|
"dependencies": dependencies,
|
||||||
|
"weight": dependents + dependencies,
|
||||||
"is_merge_target": issue.ID == target.ID,
|
"is_merge_target": issue.ID == target.ID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,38 @@ func TestChooseMergeTarget(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantID: "bd-2", // Dependents take priority
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user