Split internal/storage/sqlite/queries.go (1586 lines) into logical modules: - queries.go (650 lines) - Core CRUD: CreateIssue, GetIssue, UpdateIssue, CloseIssue - queries_search.go (429 lines) - Search/filter: SearchIssues, GetIssueByExternalRef, GetCloseReason, GetCloseReasonsForIssues - queries_delete.go (464 lines) - Delete operations: CreateTombstone, DeleteIssue, DeleteIssues and cascade logic - queries_rename.go (149 lines) - ID/prefix operations: UpdateIssueID, RenameDependencyPrefix, RenameCounterPrefix, ResetCounter - queries_helpers.go (50 lines) - Utilities: parseNullableTimeString, parseJSONStringArray, formatJSONStringArray All tests pass. No functional changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
430 lines
13 KiB
Go
430 lines
13 KiB
Go
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)
|
|
}
|