package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "github.com/steveyegge/beads/internal/types" ) // REMOVED (bd-8e05): getNextIDForPrefix and AllocateNextID - sequential ID generation // no longer needed with hash-based IDs // Migration functions moved to migrations.go (bd-fc2d, bd-b245) // getNextChildNumber atomically generates the next child number for a parent ID // Uses the child_counters table for atomic, cross-process child ID generation // Hash ID generation functions moved to hash_ids.go (bd-90a5) // REMOVED (bd-c7af): SyncAllCounters - no longer needed with hash IDs // REMOVED (bd-166): derivePrefixFromPath was causing duplicate issues with wrong prefix // The database should ALWAYS have issue_prefix config set explicitly (by 'bd init' or auto-import) // Never derive prefix from filename - it leads to silent data corruption // CreateIssue creates a new issue func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error { // Validate issue before creating if err := issue.Validate(); err != nil { return fmt.Errorf("validation failed: %w", err) } // Set timestamps now := time.Now() issue.CreatedAt = now issue.UpdatedAt = now // Compute content hash (bd-95) if issue.ContentHash == "" { issue.ContentHash = issue.ComputeContentHash() } // Acquire a dedicated connection for the transaction. // This is necessary because we need to execute raw SQL ("BEGIN IMMEDIATE", "COMMIT") // on the same connection, and database/sql's connection pool would otherwise // use different connections for different queries. conn, err := s.db.Conn(ctx) if err != nil { return fmt.Errorf("failed to acquire connection: %w", err) } defer func() { _ = conn.Close() }() // Start IMMEDIATE transaction to acquire write lock early and prevent race conditions. // IMMEDIATE acquires a RESERVED lock immediately, preventing other IMMEDIATE or EXCLUSIVE // transactions from starting. This serializes ID generation across concurrent writers. // // We use raw Exec instead of BeginTx because database/sql doesn't support transaction // modes in BeginTx, and modernc.org/sqlite's BeginTx always uses DEFERRED mode. if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil { return fmt.Errorf("failed to begin immediate transaction: %w", err) } // Track commit state for defer cleanup // Use context.Background() for ROLLBACK to ensure cleanup happens even if ctx is canceled committed := false defer func() { if !committed { _, _ = conn.ExecContext(context.Background(), "ROLLBACK") } }() // Get prefix from config (needed for both ID generation and validation) var prefix string err = conn.QueryRowContext(ctx, `SELECT value FROM config WHERE key = ?`, "issue_prefix").Scan(&prefix) if err == sql.ErrNoRows || prefix == "" { // CRITICAL: Reject operation if issue_prefix config is missing (bd-166) // This prevents duplicate issues with wrong prefix return fmt.Errorf("database not initialized: issue_prefix config is missing (run 'bd init --prefix ' first)") } else if err != nil { return fmt.Errorf("failed to get config: %w", err) } // Generate or validate ID if issue.ID == "" { // Generate hash-based ID with adaptive length based on database size (bd-ea2a13) generatedID, err := GenerateIssueID(ctx, conn, prefix, issue, actor) if err != nil { return err } issue.ID = generatedID } else { // Validate that explicitly provided ID matches the configured prefix (bd-177) if err := ValidateIssueIDPrefix(issue.ID, prefix); err != nil { return err } // For hierarchical IDs (bd-a3f8e9.1), ensure parent exists if strings.Contains(issue.ID, ".") { // Try to resurrect entire parent chain if any parents are missing // Use the conn-based version to participate in the same transaction resurrected, err := s.tryResurrectParentChainWithConn(ctx, conn, issue.ID) if err != nil { return fmt.Errorf("failed to resurrect parent chain for %s: %w", issue.ID, err) } if !resurrected { // Parent(s) not found in JSONL history - cannot proceed lastDot := strings.LastIndex(issue.ID, ".") parentID := issue.ID[:lastDot] return fmt.Errorf("parent issue %s does not exist and could not be resurrected from JSONL history", parentID) } } } // Insert issue if err := insertIssue(ctx, conn, issue); err != nil { return err } // Record creation event if err := recordCreatedEvent(ctx, conn, issue, actor); err != nil { return err } // Mark issue as dirty for incremental export if err := markDirty(ctx, conn, issue.ID); err != nil { return err } // Commit the transaction if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } committed = true return nil } // validateBatchIssues validates all issues in a batch and sets timestamps // Batch operation functions moved to batch_ops.go (bd-c796) // GetIssue retrieves an issue by ID func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, error) { var issue types.Issue var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString var externalRef sql.NullString var compactedAt sql.NullTime var originalSize sql.NullInt64 var sourceRepo sql.NullString var contentHash sql.NullString var compactedAtCommit 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 FROM issues WHERE id = ? `, id).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, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get issue: %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 externalRef.Valid { issue.ExternalRef = &externalRef.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 } // 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 } // 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 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 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, ) 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) } // 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 } // Allowed fields for update to prevent SQL injection var allowedUpdateFields = map[string]bool{ "status": true, "priority": true, "title": true, "assignee": true, "description": true, "design": true, "acceptance_criteria": true, "notes": true, "issue_type": true, "estimated_minutes": true, "external_ref": true, "closed_at": true, } // validatePriority validates a priority value // Validation functions moved to validators.go (bd-d9e0) // determineEventType determines the event type for an update based on old and new status func determineEventType(oldIssue *types.Issue, updates map[string]interface{}) types.EventType { statusVal, hasStatus := updates["status"] if !hasStatus { return types.EventUpdated } newStatus, ok := statusVal.(string) if !ok { return types.EventUpdated } if newStatus == string(types.StatusClosed) { return types.EventClosed } if oldIssue.Status == types.StatusClosed { return types.EventReopened } return types.EventStatusChanged } // manageClosedAt automatically manages the closed_at field based on status changes func manageClosedAt(oldIssue *types.Issue, updates map[string]interface{}, setClauses []string, args []interface{}) ([]string, []interface{}) { statusVal, hasStatus := updates["status"] // If closed_at is explicitly provided in updates, it's already in setClauses/args // and we should not override it (important for import operations that preserve timestamps) _, hasExplicitClosedAt := updates["closed_at"] if hasExplicitClosedAt { return setClauses, args } if !hasStatus { return setClauses, args } // Handle both string and types.Status var newStatus string switch v := statusVal.(type) { case string: newStatus = v case types.Status: newStatus = string(v) default: return setClauses, args } if newStatus == string(types.StatusClosed) { // Changing to closed: ensure closed_at is set now := time.Now() updates["closed_at"] = now setClauses = append(setClauses, "closed_at = ?") args = append(args, now) } else if oldIssue.Status == types.StatusClosed { // Changing from closed to something else: clear closed_at updates["closed_at"] = nil setClauses = append(setClauses, "closed_at = ?") args = append(args, nil) } return setClauses, args } // UpdateIssue updates fields on an issue func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error { // Get old issue for event oldIssue, err := s.GetIssue(ctx, id) if err != nil { return err } if oldIssue == nil { return fmt.Errorf("issue %s not found", id) } // Build update query with validated field names setClauses := []string{"updated_at = ?"} args := []interface{}{time.Now()} for key, value := range updates { // Prevent SQL injection by validating field names if !allowedUpdateFields[key] { return fmt.Errorf("invalid field for update: %s", key) } // Validate field values if err := validateFieldUpdate(key, value); err != nil { return err } setClauses = append(setClauses, fmt.Sprintf("%s = ?", key)) args = append(args, value) } // Auto-manage closed_at when status changes (enforce invariant) setClauses, args = manageClosedAt(oldIssue, updates, setClauses, args) // Recompute content_hash if any content fields changed (bd-95) contentChanged := false contentFields := []string{"title", "description", "design", "acceptance_criteria", "notes", "status", "priority", "issue_type", "assignee", "external_ref"} for _, field := range contentFields { if _, exists := updates[field]; exists { contentChanged = true break } } if contentChanged { // Get updated issue to compute hash updatedIssue := *oldIssue for key, value := range updates { switch key { case "title": updatedIssue.Title = value.(string) case "description": updatedIssue.Description = value.(string) case "design": updatedIssue.Design = value.(string) case "acceptance_criteria": updatedIssue.AcceptanceCriteria = value.(string) case "notes": updatedIssue.Notes = value.(string) case "status": // Handle both string and types.Status if s, ok := value.(types.Status); ok { updatedIssue.Status = s } else { updatedIssue.Status = types.Status(value.(string)) } case "priority": updatedIssue.Priority = value.(int) case "issue_type": // Handle both string and types.IssueType if t, ok := value.(types.IssueType); ok { updatedIssue.IssueType = t } else { updatedIssue.IssueType = types.IssueType(value.(string)) } case "assignee": if value == nil { updatedIssue.Assignee = "" } else { updatedIssue.Assignee = value.(string) } case "external_ref": if value == nil { updatedIssue.ExternalRef = nil } else { // Handle both string and *string switch v := value.(type) { case string: updatedIssue.ExternalRef = &v case *string: updatedIssue.ExternalRef = v default: return fmt.Errorf("external_ref must be string or *string, got %T", value) } } } } newHash := updatedIssue.ComputeContentHash() setClauses = append(setClauses, "content_hash = ?") args = append(args, newHash) } args = append(args, id) // Start transaction tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() // Update issue query := fmt.Sprintf("UPDATE issues SET %s WHERE id = ?", strings.Join(setClauses, ", ")) // #nosec G201 - safe SQL with controlled column names _, err = tx.ExecContext(ctx, query, args...) if err != nil { return fmt.Errorf("failed to update issue: %w", err) } // Record event oldData, err := json.Marshal(oldIssue) if err != nil { // Fall back to minimal description if marshaling fails oldData = []byte(fmt.Sprintf(`{"id":"%s"}`, id)) } newData, err := json.Marshal(updates) if err != nil { // Fall back to minimal description if marshaling fails newData = []byte(`{}`) } oldDataStr := string(oldData) newDataStr := string(newData) eventType := determineEventType(oldIssue, updates) _, err = tx.ExecContext(ctx, ` INSERT INTO events (issue_id, event_type, actor, old_value, new_value) VALUES (?, ?, ?, ?, ?) `, id, eventType, actor, oldDataStr, newDataStr) if err != nil { return fmt.Errorf("failed to record event: %w", err) } // Mark issue as dirty for incremental export _, err = tx.ExecContext(ctx, ` INSERT INTO dirty_issues (issue_id, marked_at) VALUES (?, ?) ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at `, id, time.Now()) if err != nil { return fmt.Errorf("failed to mark issue dirty: %w", err) } // Invalidate blocked issues cache if status changed (bd-5qim) // Status changes affect which issues are blocked (blockers must be open/in_progress/blocked) if _, statusChanged := updates["status"]; statusChanged { if err := s.invalidateBlockedCache(ctx, tx); err != nil { return fmt.Errorf("failed to invalidate blocked cache: %w", err) } } return tx.Commit() } // UpdateIssueID updates an issue ID and all its text fields in a single transaction func (s *SQLiteStorage) UpdateIssueID(ctx context.Context, oldID, newID string, issue *types.Issue, actor string) error { // Get exclusive connection to ensure PRAGMA applies conn, err := s.db.Conn(ctx) if err != nil { return fmt.Errorf("failed to get connection: %w", err) } defer func() { _ = conn.Close() }() // Disable foreign keys on this specific connection _, err = conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`) if err != nil { return fmt.Errorf("failed to disable foreign keys: %w", err) } tx, err := conn.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, ` UPDATE issues SET id = ?, title = ?, description = ?, design = ?, acceptance_criteria = ?, notes = ?, updated_at = ? WHERE id = ? `, newID, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, time.Now(), oldID) if err != nil { return fmt.Errorf("failed to update issue ID: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return fmt.Errorf("issue not found: %s", oldID) } _, err = tx.ExecContext(ctx, `UPDATE dependencies SET issue_id = ? WHERE issue_id = ?`, newID, oldID) if err != nil { return fmt.Errorf("failed to update issue_id in dependencies: %w", err) } _, err = tx.ExecContext(ctx, `UPDATE dependencies SET depends_on_id = ? WHERE depends_on_id = ?`, newID, oldID) if err != nil { return fmt.Errorf("failed to update depends_on_id in dependencies: %w", err) } _, err = tx.ExecContext(ctx, `UPDATE events SET issue_id = ? WHERE issue_id = ?`, newID, oldID) if err != nil { return fmt.Errorf("failed to update events: %w", err) } _, err = tx.ExecContext(ctx, `UPDATE labels SET issue_id = ? WHERE issue_id = ?`, newID, oldID) if err != nil { return fmt.Errorf("failed to update labels: %w", err) } _, err = tx.ExecContext(ctx, `UPDATE comments SET issue_id = ? WHERE issue_id = ?`, newID, oldID) if err != nil { return fmt.Errorf("failed to update comments: %w", err) } _, err = tx.ExecContext(ctx, ` UPDATE dirty_issues SET issue_id = ? WHERE issue_id = ? `, newID, oldID) if err != nil { return fmt.Errorf("failed to update dirty_issues: %w", err) } _, err = tx.ExecContext(ctx, `UPDATE issue_snapshots SET issue_id = ? WHERE issue_id = ?`, newID, oldID) if err != nil { return fmt.Errorf("failed to update issue_snapshots: %w", err) } _, err = tx.ExecContext(ctx, `UPDATE compaction_snapshots SET issue_id = ? WHERE issue_id = ?`, newID, oldID) if err != nil { return fmt.Errorf("failed to update compaction_snapshots: %w", err) } _, err = tx.ExecContext(ctx, ` INSERT INTO dirty_issues (issue_id, marked_at) VALUES (?, ?) ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at `, newID, time.Now()) if err != nil { return fmt.Errorf("failed to mark issue dirty: %w", err) } _, err = tx.ExecContext(ctx, ` INSERT INTO events (issue_id, event_type, actor, old_value, new_value) VALUES (?, 'renamed', ?, ?, ?) `, newID, actor, oldID, newID) if err != nil { return fmt.Errorf("failed to record rename event: %w", err) } return tx.Commit() } // RenameDependencyPrefix updates the prefix in all dependency records func (s *SQLiteStorage) RenameDependencyPrefix(ctx context.Context, oldPrefix, newPrefix string) error { return nil } // RenameCounterPrefix is a no-op with hash-based IDs (bd-8e05) // Kept for backward compatibility with rename-prefix command func (s *SQLiteStorage) RenameCounterPrefix(ctx context.Context, oldPrefix, newPrefix string) error { // Hash-based IDs don't use counters, so nothing to update return nil } // ResetCounter is a no-op with hash-based IDs (bd-8e05) // Kept for backward compatibility func (s *SQLiteStorage) ResetCounter(ctx context.Context, prefix string) error { // Hash-based IDs don't use counters, so nothing to reset return nil } // CloseIssue closes an issue with a reason func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error { now := time.Now() // Update with special event handling tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, ` UPDATE issues SET status = ?, closed_at = ?, updated_at = ? WHERE id = ? `, types.StatusClosed, now, now, id) if err != nil { return fmt.Errorf("failed to close issue: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get rows affected: %w", err) } if rows == 0 { return fmt.Errorf("issue not found: %s", id) } _, err = tx.ExecContext(ctx, ` INSERT INTO events (issue_id, event_type, actor, comment) VALUES (?, ?, ?, ?) `, id, types.EventClosed, actor, reason) if err != nil { return fmt.Errorf("failed to record event: %w", err) } // Mark issue as dirty for incremental export _, err = tx.ExecContext(ctx, ` INSERT INTO dirty_issues (issue_id, marked_at) VALUES (?, ?) ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at `, id, time.Now()) if err != nil { return fmt.Errorf("failed to mark issue dirty: %w", err) } // Invalidate blocked issues cache since status changed to closed (bd-5qim) // Closed issues don't block others, so this affects blocking calculations if err := s.invalidateBlockedCache(ctx, tx); err != nil { return fmt.Errorf("failed to invalidate blocked cache: %w", err) } return tx.Commit() } // DeleteIssue permanently removes an issue from the database func (s *SQLiteStorage) DeleteIssue(ctx context.Context, id string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() // Delete dependencies (both directions) _, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id) if err != nil { return fmt.Errorf("failed to delete dependencies: %w", err) } // Delete events _, err = tx.ExecContext(ctx, `DELETE FROM events WHERE issue_id = ?`, id) if err != nil { return fmt.Errorf("failed to delete events: %w", err) } // Delete from dirty_issues _, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id) if err != nil { return fmt.Errorf("failed to delete dirty marker: %w", err) } // Delete the issue itself result, err := tx.ExecContext(ctx, `DELETE FROM issues WHERE id = ?`, id) if err != nil { return fmt.Errorf("failed to delete issue: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to check rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("issue not found: %s", id) } if err := tx.Commit(); err != nil { return err } // REMOVED (bd-c7af): Counter sync after deletion - no longer needed with hash IDs return nil } // DeleteIssuesResult contains statistics about a batch deletion operation type DeleteIssuesResult struct { DeletedCount int DependenciesCount int LabelsCount int EventsCount int OrphanedIssues []string } // DeleteIssues deletes multiple issues in a single transaction // If cascade is true, recursively deletes dependents // If cascade is false but force is true, deletes issues and orphans their dependents // If cascade and force are both false, returns an error if any issue has dependents // If dryRun is true, only computes statistics without deleting func (s *SQLiteStorage) DeleteIssues(ctx context.Context, ids []string, cascade bool, force bool, dryRun bool) (*DeleteIssuesResult, error) { if len(ids) == 0 { return &DeleteIssuesResult{}, nil } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() idSet := buildIDSet(ids) result := &DeleteIssuesResult{} expandedIDs, err := s.resolveDeleteSet(ctx, tx, ids, idSet, cascade, force, result) if err != nil { return nil, err } inClause, args := buildSQLInClause(expandedIDs) if err := s.populateDeleteStats(ctx, tx, inClause, args, result); err != nil { return nil, err } if dryRun { return result, nil } if err := s.executeDelete(ctx, tx, inClause, args, result); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("failed to commit transaction: %w", err) } // REMOVED (bd-c7af): Counter sync after deletion - no longer needed with hash IDs return result, nil } func buildIDSet(ids []string) map[string]bool { idSet := make(map[string]bool, len(ids)) for _, id := range ids { idSet[id] = true } return idSet } func (s *SQLiteStorage) resolveDeleteSet(ctx context.Context, tx *sql.Tx, ids []string, idSet map[string]bool, cascade bool, force bool, result *DeleteIssuesResult) ([]string, error) { if cascade { return s.expandWithDependents(ctx, tx, ids, idSet) } if !force { return ids, s.validateNoDependents(ctx, tx, ids, idSet, result) } return ids, s.trackOrphanedIssues(ctx, tx, ids, idSet, result) } func (s *SQLiteStorage) expandWithDependents(ctx context.Context, tx *sql.Tx, ids []string, _ map[string]bool) ([]string, error) { allToDelete, err := s.findAllDependentsRecursive(ctx, tx, ids) if err != nil { return nil, fmt.Errorf("failed to find dependents: %w", err) } expandedIDs := make([]string, 0, len(allToDelete)) for id := range allToDelete { expandedIDs = append(expandedIDs, id) } return expandedIDs, nil } func (s *SQLiteStorage) validateNoDependents(ctx context.Context, tx *sql.Tx, ids []string, idSet map[string]bool, result *DeleteIssuesResult) error { for _, id := range ids { if err := s.checkSingleIssueValidation(ctx, tx, id, idSet, result); err != nil { return err } } return nil } func (s *SQLiteStorage) checkSingleIssueValidation(ctx context.Context, tx *sql.Tx, id string, idSet map[string]bool, result *DeleteIssuesResult) error { var depCount int err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM dependencies WHERE depends_on_id = ?`, id).Scan(&depCount) if err != nil { return fmt.Errorf("failed to check dependents for %s: %w", id, err) } if depCount == 0 { return nil } rows, err := tx.QueryContext(ctx, `SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, id) if err != nil { return fmt.Errorf("failed to get dependents for %s: %w", id, err) } defer func() { _ = rows.Close() }() hasExternal := false for rows.Next() { var depID string if err := rows.Scan(&depID); err != nil { return fmt.Errorf("failed to scan dependent: %w", err) } if !idSet[depID] { hasExternal = true result.OrphanedIssues = append(result.OrphanedIssues, depID) } } if err := rows.Err(); err != nil { return fmt.Errorf("failed to iterate dependents for %s: %w", id, err) } if hasExternal { return fmt.Errorf("issue %s has dependents not in deletion set; use --cascade to delete them or --force to orphan them", id) } return nil } func (s *SQLiteStorage) trackOrphanedIssues(ctx context.Context, tx *sql.Tx, ids []string, idSet map[string]bool, result *DeleteIssuesResult) error { orphanSet := make(map[string]bool) for _, id := range ids { if err := s.collectOrphansForID(ctx, tx, id, idSet, orphanSet); err != nil { return err } } for orphanID := range orphanSet { result.OrphanedIssues = append(result.OrphanedIssues, orphanID) } return nil } func (s *SQLiteStorage) collectOrphansForID(ctx context.Context, tx *sql.Tx, id string, idSet map[string]bool, orphanSet map[string]bool) error { rows, err := tx.QueryContext(ctx, `SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, id) if err != nil { return fmt.Errorf("failed to get dependents for %s: %w", id, err) } defer func() { _ = rows.Close() }() for rows.Next() { var depID string if err := rows.Scan(&depID); err != nil { return fmt.Errorf("failed to scan dependent: %w", err) } if !idSet[depID] { orphanSet[depID] = true } } return rows.Err() } func buildSQLInClause(ids []string) (string, []interface{}) { placeholders := make([]string, len(ids)) args := make([]interface{}, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } return strings.Join(placeholders, ","), args } func (s *SQLiteStorage) populateDeleteStats(ctx context.Context, tx *sql.Tx, inClause string, args []interface{}, result *DeleteIssuesResult) error { counts := []struct { query string dest *int }{ {fmt.Sprintf(`SELECT COUNT(*) FROM dependencies WHERE issue_id IN (%s) OR depends_on_id IN (%s)`, inClause, inClause), &result.DependenciesCount}, {fmt.Sprintf(`SELECT COUNT(*) FROM labels WHERE issue_id IN (%s)`, inClause), &result.LabelsCount}, {fmt.Sprintf(`SELECT COUNT(*) FROM events WHERE issue_id IN (%s)`, inClause), &result.EventsCount}, } for _, c := range counts { queryArgs := args if c.dest == &result.DependenciesCount { queryArgs = append(args, args...) } if err := tx.QueryRowContext(ctx, c.query, queryArgs...).Scan(c.dest); err != nil { return fmt.Errorf("failed to count: %w", err) } } result.DeletedCount = len(args) return nil } func (s *SQLiteStorage) executeDelete(ctx context.Context, tx *sql.Tx, inClause string, args []interface{}, result *DeleteIssuesResult) error { deletes := []struct { query string args []interface{} }{ {fmt.Sprintf(`DELETE FROM dependencies WHERE issue_id IN (%s) OR depends_on_id IN (%s)`, inClause, inClause), append(args, args...)}, {fmt.Sprintf(`DELETE FROM labels WHERE issue_id IN (%s)`, inClause), args}, {fmt.Sprintf(`DELETE FROM events WHERE issue_id IN (%s)`, inClause), args}, {fmt.Sprintf(`DELETE FROM dirty_issues WHERE issue_id IN (%s)`, inClause), args}, {fmt.Sprintf(`DELETE FROM issues WHERE id IN (%s)`, inClause), args}, } for i, d := range deletes { execResult, err := tx.ExecContext(ctx, d.query, d.args...) if err != nil { return fmt.Errorf("failed to delete: %w", err) } if i == len(deletes)-1 { rowsAffected, err := execResult.RowsAffected() if err != nil { return fmt.Errorf("failed to check rows affected: %w", err) } result.DeletedCount = int(rowsAffected) } } return nil } // findAllDependentsRecursive finds all issues that depend on the given issues, recursively func (s *SQLiteStorage) findAllDependentsRecursive(ctx context.Context, tx *sql.Tx, ids []string) (map[string]bool, error) { result := make(map[string]bool) for _, id := range ids { result[id] = true } toProcess := make([]string, len(ids)) copy(toProcess, ids) for len(toProcess) > 0 { current := toProcess[0] toProcess = toProcess[1:] rows, err := tx.QueryContext(ctx, `SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, current) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var depID string if err := rows.Scan(&depID); err != nil { return nil, err } if !result[depID] { result[depID] = true toProcess = append(toProcess, depID) } } if err := rows.Err(); err != nil { return nil, err } } return result, nil } // SearchIssues finds issues matching query and filters func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) { 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) } 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, ", "))) } 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 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) }