feat(types): add tombstone support for inline soft-delete (bd-fbj)

Add tombstone types and schema migration as foundation for the tombstone
epic (bd-vw8) which replaces deletions.jsonl with inline tombstones.

Changes:
- Add tombstone fields to Issue struct: DeletedAt, DeletedBy, DeleteReason, OriginalType
- Add StatusTombstone constant and IsTombstone() helper method
- Update Status.IsValid() to accept tombstone status
- Create migration 018_tombstone_columns.go for new database columns
- Update schema.go with tombstone columns: deleted_at, deleted_by, delete_reason, original_type
- Update all issue insert/update/scan operations across:
  - issues.go (insertIssue, insertIssues)
  - queries.go (GetIssue, GetIssueByExternalRef, SearchIssues)
  - dependencies.go (scanIssues, scanIssuesWithDependencyType)
  - transaction.go (scanIssueRow, GetIssue, SearchIssues)
  - multirepo.go (import operations)
  - ready.go (GetReadyWork, GetStaleIssues)
  - labels.go (GetIssuesByLabel)
- Add test for IsTombstone() helper
- Update migration test to include tombstone columns

Unblocks: bd-olt (TTL logic), bd-3b4 (delete command), bd-0ih (merge updates), bd-dve (import/export)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-05 15:29:42 -08:00
parent ce119551f6
commit 08e43d9fc7
13 changed files with 295 additions and 25 deletions

View File

@@ -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, 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.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.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 d.type
FROM issues i FROM issues i
JOIN dependencies d ON i.id = d.depends_on_id 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, 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.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.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 d.type
FROM issues i FROM issues i
JOIN dependencies d ON i.id = d.issue_id 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 externalRef sql.NullString
var sourceRepo sql.NullString var sourceRepo sql.NullString
var closeReason 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( err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", err) 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 { if closeReason.Valid {
issue.CloseReason = closeReason.String 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) issues = append(issues, &issue)
issueIDs = append(issueIDs, issue.ID) issueIDs = append(issueIDs, issue.ID)
@@ -754,6 +773,10 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
var assignee sql.NullString var assignee sql.NullString
var externalRef sql.NullString var externalRef sql.NullString
var sourceRepo 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 var depType types.DependencyType
err := rows.Scan( err := rows.Scan(
@@ -761,6 +784,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&depType, &depType,
) )
if err != nil { if err != nil {
@@ -786,6 +810,18 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
if sourceRepo.Valid { if sourceRepo.Valid {
issue.SourceRepo = sourceRepo.String 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 // Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID) labels, err := s.GetLabels(ctx, issue.ID)

View File

@@ -19,14 +19,16 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
INSERT INTO issues ( INSERT INTO issues (
id, content_hash, title, description, design, acceptance_criteria, notes, id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, 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,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) deleted_at, deleted_by, delete_reason, original_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.AcceptanceCriteria, issue.Notes, issue.Status,
issue.Priority, issue.IssueType, issue.Assignee, issue.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to insert issue: %w", err) 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 ( INSERT INTO issues (
id, content_hash, title, description, design, acceptance_criteria, notes, id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, 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,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) deleted_at, deleted_by, delete_reason, original_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`) `)
if err != nil { if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err) 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.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err) return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)

View File

@@ -157,7 +157,8 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, 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.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 FROM issues i
JOIN labels l ON i.id = l.issue_id JOIN labels l ON i.id = l.issue_id
WHERE l.label = ? WHERE l.label = ?

View File

@@ -34,6 +34,7 @@ var migrationsList = []Migration{
{"blocked_issues_cache", migrations.MigrateBlockedIssuesCache}, {"blocked_issues_cache", migrations.MigrateBlockedIssuesCache},
{"orphan_detection", migrations.MigrateOrphanDetection}, {"orphan_detection", migrations.MigrateOrphanDetection},
{"close_reason_column", migrations.MigrateCloseReasonColumn}, {"close_reason_column", migrations.MigrateCloseReasonColumn},
{"tombstone_columns", migrations.MigrateTombstoneColumns},
} }
// MigrationInfo contains metadata about a migration for inspection // 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)", "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)", "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)", "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 { if desc, ok := descriptions[name]; ok {

View File

@@ -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
}

View File

@@ -464,7 +464,7 @@ func TestMigrateContentHashColumn(t *testing.T) {
design TEXT NOT NULL DEFAULT '', design TEXT NOT NULL DEFAULT '',
acceptance_criteria TEXT NOT NULL DEFAULT '', acceptance_criteria TEXT NOT NULL DEFAULT '',
notes 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, priority INTEGER NOT NULL,
issue_type TEXT NOT NULL CHECK (issue_type IN ('bug', 'feature', 'task', 'epic', 'chore')), issue_type TEXT NOT NULL CHECK (issue_type IN ('bug', 'feature', 'task', 'epic', 'chore')),
assignee TEXT, assignee TEXT,
@@ -479,9 +479,13 @@ func TestMigrateContentHashColumn(t *testing.T) {
compacted_at_commit TEXT, compacted_at_commit TEXT,
source_repo TEXT DEFAULT '.', source_repo TEXT DEFAULT '.',
close_reason 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)) 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; DROP TABLE issues_backup;
`) `)
if err != nil { if err != nil {

View File

@@ -242,14 +242,16 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
INSERT INTO issues ( INSERT INTO issues (
id, content_hash, title, description, design, acceptance_criteria, notes, id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, 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,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) deleted_at, deleted_by, delete_reason, original_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.AcceptanceCriteria, issue.Notes, issue.Status,
issue.Priority, issue.IssueType, issue.Assignee, issue.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to insert issue: %w", err) 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 = ?, content_hash = ?, title = ?, description = ?, design = ?,
acceptance_criteria = ?, notes = ?, status = ?, priority = ?, acceptance_criteria = ?, notes = ?, status = ?, priority = ?,
issue_type = ?, assignee = ?, estimated_minutes = ?, 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 = ? WHERE id = ?
`, `,
issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.Priority, issue.AcceptanceCriteria, issue.Notes, issue.Status, issue.Priority,
issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.IssueType, issue.Assignee, issue.EstimatedMinutes,
issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.ID, issue.ID,
) )
if err != nil { if err != nil {

View File

@@ -158,6 +158,10 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
var originalSize sql.NullInt64 var originalSize sql.NullInt64
var sourceRepo sql.NullString var sourceRepo sql.NullString
var closeReason 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 contentHash sql.NullString
var compactedAtCommit 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, SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, 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 FROM issues
WHERE id = ? WHERE id = ?
`, id).Scan( `, id).Scan(
@@ -174,6 +179,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -214,6 +220,18 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
if closeReason.Valid { if closeReason.Valid {
issue.CloseReason = closeReason.String 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 // Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID) 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 originalSize sql.NullInt64
var contentHash sql.NullString var contentHash sql.NullString
var compactedAtCommit 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, ` err := s.db.QueryRowContext(ctx, `
SELECT id, content_hash, title, description, design, acceptance_criteria, notes, SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, 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 FROM issues
WHERE external_ref = ? WHERE external_ref = ?
`, externalRef).Scan( `, externalRef).Scan(
@@ -324,7 +349,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol, &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 { if err == sql.ErrNoRows {
@@ -359,6 +385,24 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
if originalSize.Valid { if originalSize.Valid {
issue.OriginalSize = int(originalSize.Int64) 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 // Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID) 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(` querySQL := fmt.Sprintf(`
SELECT id, content_hash, title, description, design, acceptance_criteria, notes, SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, 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 FROM issues
%s %s
ORDER BY priority ASC, created_at DESC ORDER BY priority ASC, created_at DESC

View File

@@ -99,7 +99,8 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, 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.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 FROM issues i
WHERE %s WHERE %s
AND NOT EXISTS ( 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, id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo, 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 FROM issues
WHERE status != 'closed' WHERE status != 'closed'
AND datetime(updated_at) < datetime('now', '-' || ? || ' days') AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
` `
args := []interface{}{filter.Days} args := []interface{}{filter.Days}
// Add optional status filter // Add optional status filter
if filter.Status != "" { if filter.Status != "" {
query += " AND status = ?" query += " AND status = ?"
args = append(args, filter.Status) args = append(args, filter.Status)
} }
query += " ORDER BY updated_at ASC" query += " ORDER BY updated_at ASC"
// Add limit // Add limit
if filter.Limit > 0 { if filter.Limit > 0 {
query += " LIMIT ?" query += " LIMIT ?"
args = append(args, filter.Limit) args = append(args, filter.Limit)
} }
rows, err := s.db.QueryContext(ctx, query, args...) rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query stale issues: %w", err) return nil, fmt.Errorf("failed to query stale issues: %w", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
var issues []*types.Issue var issues []*types.Issue
for rows.Next() { for rows.Next() {
var issue types.Issue var issue types.Issue
@@ -168,6 +170,10 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
var compactedAtCommit sql.NullString var compactedAtCommit sql.NullString
var originalSize sql.NullInt64 var originalSize sql.NullInt64
var closeReason 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( err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &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.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason, &compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan stale issue: %w", err) 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 { if closeReason.Valid {
issue.CloseReason = closeReason.String 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) issues = append(issues, &issue)
} }

View File

@@ -23,6 +23,10 @@ CREATE TABLE IF NOT EXISTS issues (
compacted_at DATETIME, compacted_at DATETIME,
compacted_at_commit TEXT, compacted_at_commit TEXT,
original_size INTEGER, 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)) CHECK ((status = 'closed') = (closed_at IS NOT NULL))
); );

View File

@@ -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, SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, 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 FROM issues
WHERE id = ? WHERE id = ?
`, 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, SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes, status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, 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 FROM issues
%s %s
ORDER BY priority ASC, created_at DESC ORDER BY priority ASC, created_at DESC
@@ -1062,6 +1064,10 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
var sourceRepo sql.NullString var sourceRepo sql.NullString
var compactedAtCommit sql.NullString var compactedAtCommit sql.NullString
var closeReason 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( err := row.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &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.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan issue: %w", err) return nil, fmt.Errorf("failed to scan issue: %w", err)
@@ -1105,6 +1112,18 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
if closeReason.Valid { if closeReason.Valid {
issue.CloseReason = closeReason.String 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 return &issue, nil
} }

View File

@@ -34,6 +34,11 @@ type Issue struct {
Labels []string `json:"labels,omitempty"` // Populated only for export/import Labels []string `json:"labels,omitempty"` // Populated only for export/import
Dependencies []*Dependency `json:"dependencies,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 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. // 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)) 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) // Validate checks if the issue has valid field values (built-in statuses only)
func (i *Issue) Validate() error { func (i *Issue) Validate() error {
return i.ValidateWithCustomStatuses(nil) return i.ValidateWithCustomStatuses(nil)
@@ -114,12 +124,13 @@ const (
StatusInProgress Status = "in_progress" StatusInProgress Status = "in_progress"
StatusBlocked Status = "blocked" StatusBlocked Status = "blocked"
StatusClosed Status = "closed" StatusClosed Status = "closed"
StatusTombstone Status = "tombstone" // Soft-deleted issue (bd-vw8)
) )
// IsValid checks if the status value is valid (built-in statuses only) // IsValid checks if the status value is valid (built-in statuses only)
func (s Status) IsValid() bool { func (s Status) IsValid() bool {
switch s { switch s {
case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed: case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed, StatusTombstone:
return true return true
} }
return false return false

View File

@@ -202,6 +202,7 @@ func TestStatusIsValid(t *testing.T) {
{StatusInProgress, true}, {StatusInProgress, true},
{StatusBlocked, true}, {StatusBlocked, true},
{StatusClosed, true}, {StatusClosed, true},
{StatusTombstone, true},
{Status("invalid"), false}, {Status("invalid"), false},
{Status(""), 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) { func TestStatusIsValidWithCustom(t *testing.T) {
customStatuses := []string{"awaiting_review", "awaiting_testing", "awaiting_docs"} customStatuses := []string{"awaiting_review", "awaiting_testing", "awaiting_docs"}