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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = ?
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
47
internal/storage/sqlite/migrations/018_tombstone_columns.go
Normal file
47
internal/storage/sqlite/migrations/018_tombstone_columns.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +127,8 @@ 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')
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user