diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index c94ef5a1..031f978e 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -269,6 +269,9 @@ var statsCmd = &cobra.Command{ fmt.Printf("Closed: %d\n", stats.ClosedIssues) fmt.Printf("Blocked: %d\n", stats.BlockedIssues) fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues))) + if stats.TombstoneIssues > 0 { + fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) + } if stats.AverageLeadTime > 0 { fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime) } @@ -307,6 +310,9 @@ var statsCmd = &cobra.Command{ fmt.Printf("Closed: %d\n", stats.ClosedIssues) fmt.Printf("Blocked: %d\n", stats.BlockedIssues) fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues))) + if stats.TombstoneIssues > 0 { + fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) + } if stats.EpicsEligibleForClosure > 0 { fmt.Printf("Epics Ready to Close: %s\n", green(fmt.Sprintf("%d", stats.EpicsEligibleForClosure))) } diff --git a/internal/storage/sqlite/events.go b/internal/storage/sqlite/events.go index 9409102c..b34e301f 100644 --- a/internal/storage/sqlite/events.go +++ b/internal/storage/sqlite/events.go @@ -110,15 +110,16 @@ func (s *SQLiteStorage) GetEvents(ctx context.Context, issueID string, limit int func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) { var stats types.Statistics - // Get counts + // Get counts (bd-nyt: exclude tombstones from TotalIssues, report separately) err := s.db.QueryRowContext(ctx, ` SELECT - COUNT(*) as total, + COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total, COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) as open, COALESCE(SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress, - COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed + COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed, + COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone FROM issues - `).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues) + `).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.TombstoneIssues) if err != nil { return nil, fmt.Errorf("failed to get issue counts: %w", err) } diff --git a/internal/storage/sqlite/migrations/018_tombstone_columns.go b/internal/storage/sqlite/migrations/018_tombstone_columns.go index 6462fe17..32711f75 100644 --- a/internal/storage/sqlite/migrations/018_tombstone_columns.go +++ b/internal/storage/sqlite/migrations/018_tombstone_columns.go @@ -43,5 +43,12 @@ func MigrateTombstoneColumns(db *sql.DB) error { } } + // Add partial index on deleted_at for efficient TTL queries (bd-saa) + // Only indexes non-NULL values, making it very efficient for tombstone filtering + _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_deleted_at ON issues(deleted_at) WHERE deleted_at IS NOT NULL`) + if err != nil { + return fmt.Errorf("failed to create deleted_at index: %w", err) + } + return nil } diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index ee2fc01d..76ad6932 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -1186,6 +1186,10 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t if filter.Status != nil { whereClauses = append(whereClauses, "status = ?") args = append(args, *filter.Status) + } else if !filter.IncludeTombstones { + // Exclude tombstones by default unless explicitly filtering for them (bd-1bu) + whereClauses = append(whereClauses, "status != ?") + args = append(args, types.StatusTombstone) } if filter.Priority != nil { diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index d002808c..9689ee8b 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -919,6 +919,10 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter if filter.Status != nil { whereClauses = append(whereClauses, "status = ?") args = append(args, *filter.Status) + } else if !filter.IncludeTombstones { + // Exclude tombstones by default unless explicitly filtering for them (bd-1bu) + whereClauses = append(whereClauses, "status != ?") + args = append(args, types.StatusTombstone) } if filter.Priority != nil { diff --git a/internal/types/types.go b/internal/types/types.go index fdb5e04d..e2e045a5 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -297,6 +297,7 @@ type Statistics struct { ClosedIssues int `json:"closed_issues"` BlockedIssues int `json:"blocked_issues"` ReadyIssues int `json:"ready_issues"` + TombstoneIssues int `json:"tombstone_issues"` // Soft-deleted issues (bd-nyt) EpicsEligibleForClosure int `json:"epics_eligible_for_closure"` AverageLeadTime float64 `json:"average_lead_time_hours"` } @@ -334,6 +335,9 @@ type IssueFilter struct { // Numeric ranges PriorityMin *int PriorityMax *int + + // Tombstone filtering (bd-1bu) + IncludeTombstones bool // If false (default), exclude tombstones from results } // SortPolicy determines how ready work is ordered