fix(orphan): handle prefixes with dots in orphan detection (GH#508)

The orphan detection was incorrectly flagging issues with dots in their
prefix (e.g., "my.project-abc123") as orphans because it was looking for
any dot in the ID, treating everything before the first dot as the
parent ID.

The fix:
- Add IsHierarchicalID() helper that correctly detects hierarchical IDs
  by checking if the ID ends with .{digits} (e.g., "bd-abc.1")
- Update SQL query in orphan detection migration to use GLOB patterns
  that only match IDs ending with numeric suffixes
- Update all Go code that checks for hierarchical IDs to use the new
  helper function

Test cases added:
- Unit tests for IsHierarchicalID covering normal, dotted prefix, and
  edge cases
- Integration test verifying dotted prefixes do not trigger false
  positives

Fixes: #508
This commit is contained in:
Steve Yegge
2025-12-14 17:23:46 -08:00
parent 768db19635
commit fb20e43f5f
6 changed files with 270 additions and 17 deletions

View File

@@ -620,4 +620,83 @@ func TestMigrateOrphanDetection(t *testing.T) {
}
}
})
// GH#508: Verify that prefixes with dots don't trigger false positives
t.Run("prefix with dots is not flagged as orphan", func(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
db := store.db
// Override the prefix for this test
_, err := db.Exec(`UPDATE config SET value = 'my.project' WHERE key = 'issue_prefix'`)
if err != nil {
t.Fatalf("failed to update prefix: %v", err)
}
// Insert issues with dotted prefix directly (bypassing prefix validation)
testCases := []struct {
id string
expectOrphan bool
}{
// These should NOT be flagged as orphans (dots in prefix)
{"my.project-abc123", false},
{"my.project-xyz789", false},
{"com.example.app-issue1", false},
// This SHOULD be flagged as orphan (hierarchical, parent doesn't exist)
{"my.project-missing.1", true},
}
for _, tc := range testCases {
_, err := db.Exec(`
INSERT INTO issues (id, title, status, priority, issue_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, tc.id, "Test Issue", "open", 1, "task")
if err != nil {
t.Fatalf("failed to insert %s: %v", tc.id, err)
}
}
// Query for orphans using the same logic as the migration
rows, err := db.Query(`
SELECT id
FROM issues
WHERE
(id GLOB '*.[0-9]' OR id GLOB '*.[0-9][0-9]' OR id GLOB '*.[0-9][0-9][0-9]' OR id GLOB '*.[0-9][0-9][0-9][0-9]')
AND rtrim(rtrim(id, '0123456789'), '.') NOT IN (SELECT id FROM issues)
ORDER BY id
`)
if err != nil {
t.Fatalf("query failed: %v", err)
}
defer rows.Close()
var orphans []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scan failed: %v", err)
}
orphans = append(orphans, id)
}
// Verify only the expected orphan is detected
if len(orphans) != 1 {
t.Errorf("expected 1 orphan, got %d: %v", len(orphans), orphans)
}
if len(orphans) == 1 && orphans[0] != "my.project-missing.1" {
t.Errorf("expected orphan 'my.project-missing.1', got %q", orphans[0])
}
// Verify non-hierarchical dotted IDs are NOT flagged
for _, tc := range testCases {
if !tc.expectOrphan {
for _, orphan := range orphans {
if orphan == tc.id {
t.Errorf("false positive: %s was incorrectly flagged as orphan", tc.id)
}
}
}
}
})
}