package sqlite import ( "context" "database/sql" "fmt" "strings" "time" "github.com/steveyegge/beads/internal/types" ) // GetCloseReason retrieves the close reason from the most recent closed event for an issue func (s *SQLiteStorage) GetCloseReason(ctx context.Context, issueID string) (string, error) { var comment sql.NullString err := s.db.QueryRowContext(ctx, ` SELECT comment FROM events WHERE issue_id = ? AND event_type = ? ORDER BY created_at DESC LIMIT 1 `, issueID, types.EventClosed).Scan(&comment) if err == sql.ErrNoRows { return "", nil } if err != nil { return "", fmt.Errorf("failed to get close reason: %w", err) } if comment.Valid { return comment.String, nil } return "", nil } // GetCloseReasonsForIssues retrieves close reasons for multiple issues in a single query func (s *SQLiteStorage) GetCloseReasonsForIssues(ctx context.Context, issueIDs []string) (map[string]string, error) { result := make(map[string]string) if len(issueIDs) == 0 { return result, nil } // Build placeholders for IN clause placeholders := make([]string, len(issueIDs)) args := make([]interface{}, len(issueIDs)+1) args[0] = types.EventClosed for i, id := range issueIDs { placeholders[i] = "?" args[i+1] = id } // Use a subquery to get the most recent closed event for each issue // #nosec G201 - safe SQL with controlled formatting query := fmt.Sprintf(` SELECT e.issue_id, e.comment FROM events e INNER JOIN ( SELECT issue_id, MAX(created_at) as max_created_at FROM events WHERE event_type = ? AND issue_id IN (%s) GROUP BY issue_id ) latest ON e.issue_id = latest.issue_id AND e.created_at = latest.max_created_at WHERE e.event_type = ? `, strings.Join(placeholders, ", ")) // Append event_type again for the outer WHERE clause args = append(args, types.EventClosed) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("failed to get close reasons: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { var issueID string var comment sql.NullString if err := rows.Scan(&issueID, &comment); err != nil { return nil, fmt.Errorf("failed to scan close reason: %w", err) } if comment.Valid && comment.String != "" { result[issueID] = comment.String } } return result, nil } // GetIssueByExternalRef retrieves an issue by external reference func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error) { var issue types.Issue var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString var externalRefCol sql.NullString var compactedAt sql.NullTime var originalSize sql.NullInt64 var contentHash sql.NullString var compactedAtCommit sql.NullString var sourceRepo sql.NullString var closeReason sql.NullString var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString // Messaging fields (bd-kwro) var sender sql.NullString var wisp sql.NullInt64 // Pinned field (bd-7h5) var pinned sql.NullInt64 // Template field (beads-1ra) var isTemplate sql.NullInt64 // Gate fields (bd-udsi) var awaitType sql.NullString var awaitID sql.NullString var timeoutNs sql.NullInt64 var waiters sql.NullString err := s.db.QueryRowContext(ctx, ` SELECT id, content_hash, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, sender, ephemeral, pinned, is_template, await_type, await_id, timeout_ns, waiters FROM issues WHERE external_ref = ? `, externalRef).Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &wisp, &pinned, &isTemplate, &awaitType, &awaitID, &timeoutNs, &waiters, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get issue by external_ref: %w", err) } if contentHash.Valid { issue.ContentHash = contentHash.String } if closedAt.Valid { issue.ClosedAt = &closedAt.Time } if estimatedMinutes.Valid { mins := int(estimatedMinutes.Int64) issue.EstimatedMinutes = &mins } if assignee.Valid { issue.Assignee = assignee.String } if externalRefCol.Valid { issue.ExternalRef = &externalRefCol.String } if compactedAt.Valid { issue.CompactedAt = &compactedAt.Time } if compactedAtCommit.Valid { issue.CompactedAtCommit = &compactedAtCommit.String } if originalSize.Valid { issue.OriginalSize = int(originalSize.Int64) } if sourceRepo.Valid { issue.SourceRepo = sourceRepo.String } if closeReason.Valid { issue.CloseReason = closeReason.String } issue.DeletedAt = parseNullableTimeString(deletedAt) if deletedBy.Valid { issue.DeletedBy = deletedBy.String } if deleteReason.Valid { issue.DeleteReason = deleteReason.String } if originalType.Valid { issue.OriginalType = originalType.String } // Messaging fields (bd-kwro) if sender.Valid { issue.Sender = sender.String } if wisp.Valid && wisp.Int64 != 0 { issue.Wisp = true } // Pinned field (bd-7h5) if pinned.Valid && pinned.Int64 != 0 { issue.Pinned = true } // Template field (beads-1ra) if isTemplate.Valid && isTemplate.Int64 != 0 { issue.IsTemplate = true } // Gate fields (bd-udsi) if awaitType.Valid { issue.AwaitType = awaitType.String } if awaitID.Valid { issue.AwaitID = awaitID.String } if timeoutNs.Valid { issue.Timeout = time.Duration(timeoutNs.Int64) } if waiters.Valid && waiters.String != "" { issue.Waiters = parseJSONStringArray(waiters.String) } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) if err != nil { return nil, fmt.Errorf("failed to get labels: %w", err) } issue.Labels = labels return &issue, nil } // SearchIssues finds issues matching query and filters func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) { // Check for external database file modifications (daemon mode) s.checkFreshness() // Hold read lock during database operations to prevent reconnect() from // closing the connection mid-query (GH#607 race condition fix) s.reconnectMu.RLock() defer s.reconnectMu.RUnlock() whereClauses := []string{} args := []interface{}{} if query != "" { whereClauses = append(whereClauses, "(title LIKE ? OR description LIKE ? OR id LIKE ?)") pattern := "%" + query + "%" args = append(args, pattern, pattern, pattern) } if filter.TitleSearch != "" { whereClauses = append(whereClauses, "title LIKE ?") pattern := "%" + filter.TitleSearch + "%" args = append(args, pattern) } // Pattern matching if filter.TitleContains != "" { whereClauses = append(whereClauses, "title LIKE ?") args = append(args, "%"+filter.TitleContains+"%") } if filter.DescriptionContains != "" { whereClauses = append(whereClauses, "description LIKE ?") args = append(args, "%"+filter.DescriptionContains+"%") } if filter.NotesContains != "" { whereClauses = append(whereClauses, "notes LIKE ?") args = append(args, "%"+filter.NotesContains+"%") } 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 { whereClauses = append(whereClauses, "priority = ?") args = append(args, *filter.Priority) } // Priority ranges if filter.PriorityMin != nil { whereClauses = append(whereClauses, "priority >= ?") args = append(args, *filter.PriorityMin) } if filter.PriorityMax != nil { whereClauses = append(whereClauses, "priority <= ?") args = append(args, *filter.PriorityMax) } if filter.IssueType != nil { whereClauses = append(whereClauses, "issue_type = ?") args = append(args, *filter.IssueType) } if filter.Assignee != nil { whereClauses = append(whereClauses, "assignee = ?") args = append(args, *filter.Assignee) } // Date ranges if filter.CreatedAfter != nil { whereClauses = append(whereClauses, "created_at > ?") args = append(args, filter.CreatedAfter.Format(time.RFC3339)) } if filter.CreatedBefore != nil { whereClauses = append(whereClauses, "created_at < ?") args = append(args, filter.CreatedBefore.Format(time.RFC3339)) } if filter.UpdatedAfter != nil { whereClauses = append(whereClauses, "updated_at > ?") args = append(args, filter.UpdatedAfter.Format(time.RFC3339)) } if filter.UpdatedBefore != nil { whereClauses = append(whereClauses, "updated_at < ?") args = append(args, filter.UpdatedBefore.Format(time.RFC3339)) } if filter.ClosedAfter != nil { whereClauses = append(whereClauses, "closed_at > ?") args = append(args, filter.ClosedAfter.Format(time.RFC3339)) } if filter.ClosedBefore != nil { whereClauses = append(whereClauses, "closed_at < ?") args = append(args, filter.ClosedBefore.Format(time.RFC3339)) } // Empty/null checks if filter.EmptyDescription { whereClauses = append(whereClauses, "(description IS NULL OR description = '')") } if filter.NoAssignee { whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')") } if filter.NoLabels { whereClauses = append(whereClauses, "id NOT IN (SELECT DISTINCT issue_id FROM labels)") } // Label filtering: issue must have ALL specified labels if len(filter.Labels) > 0 { for _, label := range filter.Labels { whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM labels WHERE label = ?)") args = append(args, label) } } // Label filtering (OR): issue must have AT LEAST ONE of these labels if len(filter.LabelsAny) > 0 { placeholders := make([]string, len(filter.LabelsAny)) for i, label := range filter.LabelsAny { placeholders[i] = "?" args = append(args, label) } whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM labels WHERE label IN (%s))", strings.Join(placeholders, ", "))) } // ID filtering: match specific issue IDs if len(filter.IDs) > 0 { placeholders := make([]string, len(filter.IDs)) for i, id := range filter.IDs { placeholders[i] = "?" args = append(args, id) } whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", "))) } // Wisp filtering (bd-kwro.9) if filter.Wisp != nil { if *filter.Wisp { whereClauses = append(whereClauses, "ephemeral = 1") // SQL column is still 'ephemeral' } else { whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)") } } // Pinned filtering (bd-7h5) if filter.Pinned != nil { if *filter.Pinned { whereClauses = append(whereClauses, "pinned = 1") } else { whereClauses = append(whereClauses, "(pinned = 0 OR pinned IS NULL)") } } // Template filtering (beads-1ra) if filter.IsTemplate != nil { if *filter.IsTemplate { whereClauses = append(whereClauses, "is_template = 1") } else { whereClauses = append(whereClauses, "(is_template = 0 OR is_template IS NULL)") } } // Parent filtering (bd-yqhh): filter children by parent issue if filter.ParentID != nil { whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM dependencies WHERE type = 'parent-child' AND depends_on_id = ?)") args = append(args, *filter.ParentID) } whereSQL := "" if len(whereClauses) > 0 { whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") } limitSQL := "" if filter.Limit > 0 { limitSQL = " LIMIT ?" args = append(args, filter.Limit) } // #nosec G201 - safe SQL with controlled formatting querySQL := fmt.Sprintf(` SELECT id, content_hash, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, sender, ephemeral, pinned, is_template, await_type, await_id, timeout_ns, waiters FROM issues %s ORDER BY priority ASC, created_at DESC %s `, whereSQL, limitSQL) rows, err := s.db.QueryContext(ctx, querySQL, args...) if err != nil { return nil, fmt.Errorf("failed to search issues: %w", err) } defer func() { _ = rows.Close() }() return s.scanIssues(ctx, rows) }