diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index 4c901641..aa6dea96 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -224,6 +224,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, + i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, d.type FROM issues i JOIN dependencies d ON i.id = d.depends_on_id @@ -244,6 +245,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, + i.deleted_at, i.deleted_by, i.delete_reason, i.original_type, d.type FROM issues i JOIN dependencies d ON i.id = d.issue_id @@ -689,12 +691,17 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type var externalRef sql.NullString var sourceRepo sql.NullString var closeReason sql.NullString + var deletedAt sql.NullTime + var deletedBy sql.NullString + var deleteReason sql.NullString + var originalType sql.NullString err := rows.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, &sourceRepo, &closeReason, + &deletedAt, &deletedBy, &deleteReason, &originalType, ) if err != nil { return nil, fmt.Errorf("failed to scan issue: %w", err) @@ -722,6 +729,18 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type if closeReason.Valid { issue.CloseReason = closeReason.String } + if deletedAt.Valid { + issue.DeletedAt = &deletedAt.Time + } + if deletedBy.Valid { + issue.DeletedBy = deletedBy.String + } + if deleteReason.Valid { + issue.DeleteReason = deleteReason.String + } + if originalType.Valid { + issue.OriginalType = originalType.String + } issues = append(issues, &issue) issueIDs = append(issueIDs, issue.ID) @@ -754,6 +773,10 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * var assignee sql.NullString var externalRef sql.NullString var sourceRepo sql.NullString + var deletedAt sql.NullTime + var deletedBy sql.NullString + var deleteReason sql.NullString + var originalType sql.NullString var depType types.DependencyType err := rows.Scan( @@ -761,6 +784,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, + &deletedAt, &deletedBy, &deleteReason, &originalType, &depType, ) if err != nil { @@ -786,6 +810,18 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * if sourceRepo.Valid { issue.SourceRepo = sourceRepo.String } + if deletedAt.Valid { + issue.DeletedAt = &deletedAt.Time + } + if deletedBy.Valid { + issue.DeletedBy = deletedBy.String + } + if deleteReason.Valid { + issue.DeleteReason = deleteReason.String + } + if originalType.Valid { + issue.OriginalType = originalType.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) diff --git a/internal/storage/sqlite/issues.go b/internal/storage/sqlite/issues.go index 73b36ca1..d336c211 100644 --- a/internal/storage/sqlite/issues.go +++ b/internal/storage/sqlite/issues.go @@ -19,14 +19,16 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error INSERT INTO issues ( 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, closed_at, external_ref, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.Priority, issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, + issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, ) if err != nil { return fmt.Errorf("failed to insert issue: %w", err) @@ -40,8 +42,9 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er INSERT INTO issues ( 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, closed_at, external_ref, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -60,6 +63,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er issue.Priority, issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, + issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, ) if err != nil { return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err) diff --git a/internal/storage/sqlite/labels.go b/internal/storage/sqlite/labels.go index 8018d18c..cd4544e3 100644 --- a/internal/storage/sqlite/labels.go +++ b/internal/storage/sqlite/labels.go @@ -157,7 +157,8 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]* rows, err := s.db.QueryContext(ctx, ` SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, - i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason + i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason, + i.deleted_at, i.deleted_by, i.delete_reason, i.original_type FROM issues i JOIN labels l ON i.id = l.issue_id WHERE l.label = ? diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index 67f94fab..5500388e 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -34,6 +34,7 @@ var migrationsList = []Migration{ {"blocked_issues_cache", migrations.MigrateBlockedIssuesCache}, {"orphan_detection", migrations.MigrateOrphanDetection}, {"close_reason_column", migrations.MigrateCloseReasonColumn}, + {"tombstone_columns", migrations.MigrateTombstoneColumns}, } // MigrationInfo contains metadata about a migration for inspection @@ -75,6 +76,7 @@ func getMigrationDescription(name string) string { "blocked_issues_cache": "Adds blocked_issues_cache table for GetReadyWork performance optimization (bd-5qim)", "orphan_detection": "Detects orphaned child issues and logs them for user action (bd-3852)", "close_reason_column": "Adds close_reason column to issues table for storing closure explanations (bd-uyu)", + "tombstone_columns": "Adds tombstone columns (deleted_at, deleted_by, delete_reason, original_type) for inline soft-delete (bd-vw8)", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/018_tombstone_columns.go b/internal/storage/sqlite/migrations/018_tombstone_columns.go new file mode 100644 index 00000000..6462fe17 --- /dev/null +++ b/internal/storage/sqlite/migrations/018_tombstone_columns.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateTombstoneColumns adds tombstone support columns to the issues table. +// These columns support inline soft-delete (bd-vw8) replacing deletions.jsonl: +// - deleted_at: when the issue was deleted +// - deleted_by: who deleted the issue +// - delete_reason: why the issue was deleted +// - original_type: the issue type before deletion (for tombstones) +func MigrateTombstoneColumns(db *sql.DB) error { + columns := []struct { + name string + definition string + }{ + {"deleted_at", "TEXT"}, + {"deleted_by", "TEXT DEFAULT ''"}, + {"delete_reason", "TEXT DEFAULT ''"}, + {"original_type", "TEXT DEFAULT ''"}, + } + + for _, col := range columns { + var columnExists bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('issues') + WHERE name = ? + `, col.name).Scan(&columnExists) + if err != nil { + return fmt.Errorf("failed to check %s column: %w", col.name, err) + } + + if columnExists { + continue + } + + _, err = db.Exec(fmt.Sprintf(`ALTER TABLE issues ADD COLUMN %s %s`, col.name, col.definition)) + if err != nil { + return fmt.Errorf("failed to add %s column: %w", col.name, err) + } + } + + return nil +} diff --git a/internal/storage/sqlite/migrations_test.go b/internal/storage/sqlite/migrations_test.go index d9384d8a..2a621756 100644 --- a/internal/storage/sqlite/migrations_test.go +++ b/internal/storage/sqlite/migrations_test.go @@ -464,7 +464,7 @@ func TestMigrateContentHashColumn(t *testing.T) { design TEXT NOT NULL DEFAULT '', acceptance_criteria TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL CHECK (status IN ('open', 'in_progress', 'blocked', 'closed')), + status TEXT NOT NULL CHECK (status IN ('open', 'in_progress', 'blocked', 'closed', 'tombstone')), priority INTEGER NOT NULL, issue_type TEXT NOT NULL CHECK (issue_type IN ('bug', 'feature', 'task', 'epic', 'chore')), assignee TEXT, @@ -479,9 +479,13 @@ func TestMigrateContentHashColumn(t *testing.T) { compacted_at_commit TEXT, source_repo TEXT DEFAULT '.', close_reason TEXT DEFAULT '', + deleted_at TEXT, + deleted_by TEXT DEFAULT '', + delete_reason TEXT DEFAULT '', + original_type TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ); - INSERT INTO issues SELECT id, 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, original_size, compacted_at_commit, source_repo, '' FROM issues_backup; + INSERT INTO issues SELECT id, 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, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '' FROM issues_backup; DROP TABLE issues_backup; `) if err != nil { diff --git a/internal/storage/sqlite/multirepo.go b/internal/storage/sqlite/multirepo.go index be2cf978..a307085e 100644 --- a/internal/storage/sqlite/multirepo.go +++ b/internal/storage/sqlite/multirepo.go @@ -242,14 +242,16 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * INSERT INTO issues ( 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, closed_at, external_ref, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.Priority, issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason, + issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, ) if err != nil { return fmt.Errorf("failed to insert issue: %w", err) @@ -271,13 +273,15 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * content_hash = ?, title = ?, description = ?, design = ?, acceptance_criteria = ?, notes = ?, status = ?, priority = ?, issue_type = ?, assignee = ?, estimated_minutes = ?, - updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ? + updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?, + deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ? WHERE id = ? `, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.Priority, issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, + issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, issue.ID, ) if err != nil { diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 2c6b06e4..ee2fc01d 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -158,6 +158,10 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, var originalSize sql.NullInt64 var sourceRepo sql.NullString var closeReason sql.NullString + var deletedAt sql.NullTime + var deletedBy sql.NullString + var deleteReason sql.NullString + var originalType sql.NullString var contentHash sql.NullString var compactedAtCommit sql.NullString @@ -165,7 +169,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, 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 + compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type FROM issues WHERE id = ? `, id).Scan( @@ -174,6 +179,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, + &deletedAt, &deletedBy, &deleteReason, &originalType, ) if err == sql.ErrNoRows { @@ -214,6 +220,18 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if closeReason.Valid { issue.CloseReason = closeReason.String } + if deletedAt.Valid { + issue.DeletedAt = &deletedAt.Time + } + if deletedBy.Valid { + issue.DeletedBy = deletedBy.String + } + if deleteReason.Valid { + issue.DeleteReason = deleteReason.String + } + if originalType.Valid { + issue.OriginalType = originalType.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -311,12 +329,19 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s var originalSize sql.NullInt64 var contentHash sql.NullString var compactedAtCommit sql.NullString + var sourceRepo sql.NullString + var closeReason sql.NullString + var deletedAt sql.NullTime + var deletedBy sql.NullString + var deleteReason sql.NullString + var originalType 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 + compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type FROM issues WHERE external_ref = ? `, externalRef).Scan( @@ -324,7 +349,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol, - &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, + &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, + &deletedAt, &deletedBy, &deleteReason, &originalType, ) if err == sql.ErrNoRows { @@ -359,6 +385,24 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s if originalSize.Valid { issue.OriginalSize = int(originalSize.Int64) } + if sourceRepo.Valid { + issue.SourceRepo = sourceRepo.String + } + if closeReason.Valid { + issue.CloseReason = closeReason.String + } + if deletedAt.Valid { + issue.DeletedAt = &deletedAt.Time + } + if deletedBy.Valid { + issue.DeletedBy = deletedBy.String + } + if deleteReason.Valid { + issue.DeleteReason = deleteReason.String + } + if originalType.Valid { + issue.OriginalType = originalType.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -1249,7 +1293,8 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t 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 + created_at, updated_at, closed_at, external_ref, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type FROM issues %s ORDER BY priority ASC, created_at DESC diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index 4ed0e9e8..e4d2a297 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -99,7 +99,8 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte query := fmt.Sprintf(` SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, - i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason + i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason, + i.deleted_at, i.deleted_by, i.delete_reason, i.original_type FROM issues i WHERE %s AND NOT EXISTS ( @@ -126,34 +127,35 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi 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, - compaction_level, compacted_at, compacted_at_commit, original_size, close_reason + compaction_level, compacted_at, compacted_at_commit, original_size, close_reason, + deleted_at, deleted_by, delete_reason, original_type FROM issues WHERE status != 'closed' AND datetime(updated_at) < datetime('now', '-' || ? || ' days') ` - + args := []interface{}{filter.Days} - + // Add optional status filter if filter.Status != "" { query += " AND status = ?" args = append(args, filter.Status) } - + query += " ORDER BY updated_at ASC" - + // Add limit if filter.Limit > 0 { query += " LIMIT ?" args = append(args, filter.Limit) } - + rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("failed to query stale issues: %w", err) } defer func() { _ = rows.Close() }() - + var issues []*types.Issue for rows.Next() { var issue types.Issue @@ -168,6 +170,10 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi var compactedAtCommit sql.NullString var originalSize sql.NullInt64 var closeReason sql.NullString + var deletedAt sql.NullTime + var deletedBy sql.NullString + var deleteReason sql.NullString + var originalType sql.NullString err := rows.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -175,6 +181,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason, + &deletedAt, &deletedBy, &deleteReason, &originalType, ) if err != nil { return nil, fmt.Errorf("failed to scan stale issue: %w", err) @@ -214,6 +221,18 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi if closeReason.Valid { issue.CloseReason = closeReason.String } + if deletedAt.Valid { + issue.DeletedAt = &deletedAt.Time + } + if deletedBy.Valid { + issue.DeletedBy = deletedBy.String + } + if deleteReason.Valid { + issue.DeleteReason = deleteReason.String + } + if originalType.Valid { + issue.OriginalType = originalType.String + } issues = append(issues, &issue) } diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 0f325f1d..baa65c1e 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -23,6 +23,10 @@ CREATE TABLE IF NOT EXISTS issues ( compacted_at DATETIME, compacted_at_commit TEXT, original_size INTEGER, + deleted_at TEXT, + deleted_by TEXT DEFAULT '', + delete_reason TEXT DEFAULT '', + original_type TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ); diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index a0251d35..d002808c 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -253,7 +253,8 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue 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 + compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type FROM issues WHERE id = ? `, id) @@ -1026,7 +1027,8 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter 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 + compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, + deleted_at, deleted_by, delete_reason, original_type FROM issues %s ORDER BY priority ASC, created_at DESC @@ -1062,6 +1064,10 @@ func scanIssueRow(row scanner) (*types.Issue, error) { var sourceRepo sql.NullString var compactedAtCommit sql.NullString var closeReason sql.NullString + var deletedAt sql.NullTime + var deletedBy sql.NullString + var deleteReason sql.NullString + var originalType sql.NullString err := row.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -1069,6 +1075,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, + &deletedAt, &deletedBy, &deleteReason, &originalType, ) if err != nil { return nil, fmt.Errorf("failed to scan issue: %w", err) @@ -1105,6 +1112,18 @@ func scanIssueRow(row scanner) (*types.Issue, error) { if closeReason.Valid { issue.CloseReason = closeReason.String } + if deletedAt.Valid { + issue.DeletedAt = &deletedAt.Time + } + if deletedBy.Valid { + issue.DeletedBy = deletedBy.String + } + if deleteReason.Valid { + issue.DeleteReason = deleteReason.String + } + if originalType.Valid { + issue.OriginalType = originalType.String + } return &issue, nil } diff --git a/internal/types/types.go b/internal/types/types.go index e0040fc4..86f9a2bc 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -34,6 +34,11 @@ type Issue struct { Labels []string `json:"labels,omitempty"` // Populated only for export/import Dependencies []*Dependency `json:"dependencies,omitempty"` // Populated only for export/import Comments []*Comment `json:"comments,omitempty"` // Populated only for export/import + // Tombstone fields (bd-vw8): inline soft-delete support + DeletedAt *time.Time `json:"deleted_at,omitempty"` // When the issue was deleted + DeletedBy string `json:"deleted_by,omitempty"` // Who deleted the issue + DeleteReason string `json:"delete_reason,omitempty"` // Why the issue was deleted + OriginalType string `json:"original_type,omitempty"` // Issue type before deletion (for tombstones) } // ComputeContentHash creates a deterministic hash of the issue's content. @@ -69,6 +74,11 @@ func (i *Issue) ComputeContentHash() string { return fmt.Sprintf("%x", h.Sum(nil)) } +// IsTombstone returns true if the issue has been soft-deleted (bd-vw8) +func (i *Issue) IsTombstone() bool { + return i.Status == StatusTombstone +} + // Validate checks if the issue has valid field values (built-in statuses only) func (i *Issue) Validate() error { return i.ValidateWithCustomStatuses(nil) @@ -114,12 +124,13 @@ const ( StatusInProgress Status = "in_progress" StatusBlocked Status = "blocked" StatusClosed Status = "closed" + StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8) ) // IsValid checks if the status value is valid (built-in statuses only) func (s Status) IsValid() bool { switch s { - case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed: + case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed, StatusTombstone: return true } return false diff --git a/internal/types/types_test.go b/internal/types/types_test.go index 027dbe74..79650759 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -202,6 +202,7 @@ func TestStatusIsValid(t *testing.T) { {StatusInProgress, true}, {StatusBlocked, true}, {StatusClosed, true}, + {StatusTombstone, true}, {Status("invalid"), false}, {Status(""), false}, } @@ -215,6 +216,79 @@ func TestStatusIsValid(t *testing.T) { } } +func TestIsTombstone(t *testing.T) { + tests := []struct { + name string + issue Issue + expect bool + }{ + { + name: "tombstone issue", + issue: Issue{ + ID: "test-1", + Title: "(deleted)", + Status: StatusTombstone, + Priority: 0, + IssueType: TypeTask, + }, + expect: true, + }, + { + name: "open issue", + issue: Issue{ + ID: "test-1", + Title: "Open issue", + Status: StatusOpen, + Priority: 2, + IssueType: TypeTask, + }, + expect: false, + }, + { + name: "closed issue", + issue: Issue{ + ID: "test-1", + Title: "Closed issue", + Status: StatusClosed, + Priority: 2, + IssueType: TypeTask, + ClosedAt: timePtr(time.Now()), + }, + expect: false, + }, + { + name: "in_progress issue", + issue: Issue{ + ID: "test-1", + Title: "In progress issue", + Status: StatusInProgress, + Priority: 2, + IssueType: TypeTask, + }, + expect: false, + }, + { + name: "blocked issue", + issue: Issue{ + ID: "test-1", + Title: "Blocked issue", + Status: StatusBlocked, + Priority: 2, + IssueType: TypeTask, + }, + expect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.issue.IsTombstone(); got != tt.expect { + t.Errorf("Issue.IsTombstone() = %v, want %v", got, tt.expect) + } + }) + } +} + func TestStatusIsValidWithCustom(t *testing.T) { customStatuses := []string{"awaiting_review", "awaiting_testing", "awaiting_docs"}