feat(cleanup): protect pinned issues from cleanup/compact (bd-b2k)
Add Pinned field to Issue struct and database schema to protect issues from accidental deletion via cleanup or compaction. Changes: - Add Pinned bool field to types.Issue - Create migration 023_pinned_column.go for database schema - Filter out pinned issues in cleanup command before deletion - Add pinned check to GetTier1Candidates and GetTier2Candidates - Add pinned check to CheckEligibility for compaction - Update all SQL queries and scan functions to include pinned field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,22 @@ SEE ALSO:
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out pinned issues - they are protected from cleanup (bd-b2k)
|
||||||
|
pinnedCount := 0
|
||||||
|
filteredIssues := make([]*types.Issue, 0, len(closedIssues))
|
||||||
|
for _, issue := range closedIssues {
|
||||||
|
if issue.Pinned {
|
||||||
|
pinnedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredIssues = append(filteredIssues, issue)
|
||||||
|
}
|
||||||
|
closedIssues = filteredIssues
|
||||||
|
|
||||||
|
if pinnedCount > 0 && !jsonOutput {
|
||||||
|
fmt.Printf("Skipping %d pinned issue(s) (protected from cleanup)\n", pinnedCount)
|
||||||
|
}
|
||||||
|
|
||||||
if len(closedIssues) == 0 {
|
if len(closedIssues) == 0 {
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func (s *SQLiteStorage) GetTier1Candidates(ctx context.Context) ([]*CompactionCa
|
|||||||
AND i.closed_at IS NOT NULL
|
AND i.closed_at IS NOT NULL
|
||||||
AND i.closed_at <= datetime('now', '-' || CAST(? AS INTEGER) || ' days')
|
AND i.closed_at <= datetime('now', '-' || CAST(? AS INTEGER) || ' days')
|
||||||
AND COALESCE(i.compaction_level, 0) = 0
|
AND COALESCE(i.compaction_level, 0) = 0
|
||||||
|
AND COALESCE(i.pinned, 0) = 0 -- Exclude pinned issues (bd-b2k)
|
||||||
AND dt.dependent_id IS NULL -- No open dependents
|
AND dt.dependent_id IS NULL -- No open dependents
|
||||||
GROUP BY i.id
|
GROUP BY i.id
|
||||||
ORDER BY i.closed_at ASC
|
ORDER BY i.closed_at ASC
|
||||||
@@ -154,6 +155,7 @@ func (s *SQLiteStorage) GetTier2Candidates(ctx context.Context) ([]*CompactionCa
|
|||||||
AND i.closed_at IS NOT NULL
|
AND i.closed_at IS NOT NULL
|
||||||
AND i.closed_at <= datetime('now', '-' || CAST(? AS INTEGER) || ' days')
|
AND i.closed_at <= datetime('now', '-' || CAST(? AS INTEGER) || ' days')
|
||||||
AND i.compaction_level = 1
|
AND i.compaction_level = 1
|
||||||
|
AND COALESCE(i.pinned, 0) = 0 -- Exclude pinned issues (bd-b2k)
|
||||||
AND COALESCE(ec.event_count, 0) >= CAST(? AS INTEGER)
|
AND COALESCE(ec.event_count, 0) >= CAST(? AS INTEGER)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
-- Check for open dependents
|
-- Check for open dependents
|
||||||
@@ -196,12 +198,13 @@ func (s *SQLiteStorage) CheckEligibility(ctx context.Context, issueID string, ti
|
|||||||
var status string
|
var status string
|
||||||
var closedAt sql.NullTime
|
var closedAt sql.NullTime
|
||||||
var compactionLevel int
|
var compactionLevel int
|
||||||
|
var pinned int
|
||||||
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
err := s.db.QueryRowContext(ctx, `
|
||||||
SELECT status, closed_at, COALESCE(compaction_level, 0)
|
SELECT status, closed_at, COALESCE(compaction_level, 0), COALESCE(pinned, 0)
|
||||||
FROM issues
|
FROM issues
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, issueID).Scan(&status, &closedAt, &compactionLevel)
|
`, issueID).Scan(&status, &closedAt, &compactionLevel, &pinned)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return false, "issue not found", nil
|
return false, "issue not found", nil
|
||||||
@@ -219,6 +222,11 @@ func (s *SQLiteStorage) CheckEligibility(ctx context.Context, issueID string, ti
|
|||||||
return false, "issue has no closed_at timestamp", nil
|
return false, "issue has no closed_at timestamp", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pinned issues are protected from compaction (bd-b2k)
|
||||||
|
if pinned != 0 {
|
||||||
|
return false, "issue is pinned (protected from compaction)", nil
|
||||||
|
}
|
||||||
|
|
||||||
switch tier {
|
switch tier {
|
||||||
case 1:
|
case 1:
|
||||||
if compactionLevel != 0 {
|
if compactionLevel != 0 {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID
|
|||||||
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,
|
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||||
i.sender, i.ephemeral,
|
i.sender, i.ephemeral, i.pinned,
|
||||||
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
|
||||||
@@ -255,7 +255,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s
|
|||||||
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,
|
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||||
i.sender, i.ephemeral,
|
i.sender, i.ephemeral, i.pinned,
|
||||||
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
|
||||||
@@ -714,6 +714,8 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
|
|||||||
// Messaging fields (bd-kwro)
|
// Messaging fields (bd-kwro)
|
||||||
var sender sql.NullString
|
var sender sql.NullString
|
||||||
var ephemeral sql.NullInt64
|
var ephemeral sql.NullInt64
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
var pinned sql.NullInt64
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||||
@@ -721,7 +723,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
|
|||||||
&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,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral,
|
&sender, &ephemeral, &pinned,
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -766,6 +768,10 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
|
|||||||
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
||||||
issue.Ephemeral = true
|
issue.Ephemeral = true
|
||||||
}
|
}
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
if pinned.Valid && pinned.Int64 != 0 {
|
||||||
|
issue.Pinned = true
|
||||||
|
}
|
||||||
|
|
||||||
issues = append(issues, &issue)
|
issues = append(issues, &issue)
|
||||||
issueIDs = append(issueIDs, issue.ID)
|
issueIDs = append(issueIDs, issue.ID)
|
||||||
@@ -805,6 +811,8 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
|
|||||||
// Messaging fields (bd-kwro)
|
// Messaging fields (bd-kwro)
|
||||||
var sender sql.NullString
|
var sender sql.NullString
|
||||||
var ephemeral sql.NullInt64
|
var ephemeral sql.NullInt64
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
var pinned sql.NullInt64
|
||||||
var depType types.DependencyType
|
var depType types.DependencyType
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
@@ -813,7 +821,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
|
|||||||
&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,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral,
|
&sender, &ephemeral, &pinned,
|
||||||
&depType,
|
&depType,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -856,6 +864,10 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
|
|||||||
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
||||||
issue.Ephemeral = true
|
issue.Ephemeral = true
|
||||||
}
|
}
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
if pinned.Valid && pinned.Int64 != 0 {
|
||||||
|
issue.Pinned = true
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch labels for this issue
|
// Fetch labels for this issue
|
||||||
labels, err := s.GetLabels(ctx, issue.ID)
|
labels, err := s.GetLabels(ctx, issue.ID)
|
||||||
|
|||||||
@@ -32,14 +32,19 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
|
|||||||
ephemeral = 1
|
ephemeral = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pinned := 0
|
||||||
|
if issue.Pinned {
|
||||||
|
pinned = 1
|
||||||
|
}
|
||||||
|
|
||||||
_, err := conn.ExecContext(ctx, `
|
_, err := conn.ExecContext(ctx, `
|
||||||
INSERT OR IGNORE INTO issues (
|
INSERT OR IGNORE 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,
|
||||||
deleted_at, deleted_by, delete_reason, original_type,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) 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,
|
||||||
@@ -47,7 +52,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
|
|||||||
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,
|
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||||
issue.Sender, ephemeral,
|
issue.Sender, ephemeral, pinned,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
||||||
@@ -68,8 +73,8 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
|||||||
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,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) 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)
|
||||||
@@ -87,6 +92,11 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
|||||||
ephemeral = 1
|
ephemeral = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pinned := 0
|
||||||
|
if issue.Pinned {
|
||||||
|
pinned = 1
|
||||||
|
}
|
||||||
|
|
||||||
_, err = stmt.ExecContext(ctx,
|
_, err = stmt.ExecContext(ctx,
|
||||||
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,
|
||||||
@@ -94,7 +104,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
|||||||
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,
|
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||||
issue.Sender, ephemeral,
|
issue.Sender, ephemeral, pinned,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*
|
|||||||
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,
|
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||||
i.sender, i.ephemeral
|
i.sender, i.ephemeral, i.pinned
|
||||||
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 = ?
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ var migrationsList = []Migration{
|
|||||||
{"edge_consolidation", migrations.MigrateEdgeConsolidation},
|
{"edge_consolidation", migrations.MigrateEdgeConsolidation},
|
||||||
{"migrate_edge_fields", migrations.MigrateEdgeFields},
|
{"migrate_edge_fields", migrations.MigrateEdgeFields},
|
||||||
{"drop_edge_columns", migrations.MigrateDropEdgeColumns},
|
{"drop_edge_columns", migrations.MigrateDropEdgeColumns},
|
||||||
|
{"pinned_column", migrations.MigratePinnedColumn},
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrationInfo contains metadata about a migration for inspection
|
// MigrationInfo contains metadata about a migration for inspection
|
||||||
@@ -85,6 +86,7 @@ func getMigrationDescription(name string) string {
|
|||||||
"edge_consolidation": "Adds metadata and thread_id columns to dependencies table for edge schema consolidation (Decision 004)",
|
"edge_consolidation": "Adds metadata and thread_id columns to dependencies table for edge schema consolidation (Decision 004)",
|
||||||
"migrate_edge_fields": "Migrates existing issue fields (replies_to, relates_to, duplicate_of, superseded_by) to dependency edges (Decision 004 Phase 3)",
|
"migrate_edge_fields": "Migrates existing issue fields (replies_to, relates_to, duplicate_of, superseded_by) to dependency edges (Decision 004 Phase 3)",
|
||||||
"drop_edge_columns": "Drops deprecated edge columns (replies_to, relates_to, duplicate_of, superseded_by) from issues table (Decision 004 Phase 4)",
|
"drop_edge_columns": "Drops deprecated edge columns (replies_to, relates_to, duplicate_of, superseded_by) from issues table (Decision 004 Phase 4)",
|
||||||
|
"pinned_column": "Adds pinned column to protect issues from cleanup/compact operations (bd-b2k)",
|
||||||
}
|
}
|
||||||
|
|
||||||
if desc, ok := descriptions[name]; ok {
|
if desc, ok := descriptions[name]; ok {
|
||||||
|
|||||||
37
internal/storage/sqlite/migrations/023_pinned_column.go
Normal file
37
internal/storage/sqlite/migrations/023_pinned_column.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigratePinnedColumn adds the pinned column to the issues table.
|
||||||
|
// Pinned issues are protected from cleanup and compaction operations (bd-b2k).
|
||||||
|
func MigratePinnedColumn(db *sql.DB) error {
|
||||||
|
var columnExists bool
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT COUNT(*) > 0
|
||||||
|
FROM pragma_table_info('issues')
|
||||||
|
WHERE name = 'pinned'
|
||||||
|
`).Scan(&columnExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check pinned column: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if columnExists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`ALTER TABLE issues ADD COLUMN pinned INTEGER DEFAULT 0`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add pinned column: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add index for pinned issues (for efficient queries)
|
||||||
|
_, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_pinned ON issues(pinned) WHERE pinned = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create pinned index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -485,13 +485,14 @@ func TestMigrateContentHashColumn(t *testing.T) {
|
|||||||
original_type TEXT DEFAULT '',
|
original_type TEXT DEFAULT '',
|
||||||
sender TEXT DEFAULT '',
|
sender TEXT DEFAULT '',
|
||||||
ephemeral INTEGER DEFAULT 0,
|
ephemeral INTEGER DEFAULT 0,
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
replies_to TEXT DEFAULT '',
|
replies_to TEXT DEFAULT '',
|
||||||
relates_to TEXT DEFAULT '',
|
relates_to TEXT DEFAULT '',
|
||||||
duplicate_of TEXT DEFAULT '',
|
duplicate_of TEXT DEFAULT '',
|
||||||
superseded_by TEXT DEFAULT '',
|
superseded_by 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, '', NULL, '', '', '', '', 0, '', '', '', '' 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, '', '', '', '', 0, 0, '', '', '', '' FROM issues_backup;
|
||||||
DROP TABLE issues_backup;
|
DROP TABLE issues_backup;
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
|||||||
// Messaging fields (bd-kwro)
|
// Messaging fields (bd-kwro)
|
||||||
var sender sql.NullString
|
var sender sql.NullString
|
||||||
var ephemeral sql.NullInt64
|
var ephemeral sql.NullInt64
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
var pinned sql.NullInt64
|
||||||
|
|
||||||
var contentHash sql.NullString
|
var contentHash sql.NullString
|
||||||
var compactedAtCommit sql.NullString
|
var compactedAtCommit sql.NullString
|
||||||
@@ -257,7 +259,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
|||||||
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,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
FROM issues
|
FROM issues
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, id).Scan(
|
`, id).Scan(
|
||||||
@@ -267,7 +269,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
|||||||
&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,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral,
|
&sender, &ephemeral, &pinned,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -325,6 +327,10 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
|||||||
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
||||||
issue.Ephemeral = true
|
issue.Ephemeral = true
|
||||||
}
|
}
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
if pinned.Valid && pinned.Int64 != 0 {
|
||||||
|
issue.Pinned = true
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch labels for this issue
|
// Fetch labels for this issue
|
||||||
labels, err := s.GetLabels(ctx, issue.ID)
|
labels, err := s.GetLabels(ctx, issue.ID)
|
||||||
@@ -431,6 +437,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
|||||||
// Messaging fields (bd-kwro)
|
// Messaging fields (bd-kwro)
|
||||||
var sender sql.NullString
|
var sender sql.NullString
|
||||||
var ephemeral sql.NullInt64
|
var ephemeral sql.NullInt64
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
var pinned sql.NullInt64
|
||||||
|
|
||||||
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,
|
||||||
@@ -438,7 +446,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
|||||||
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,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
FROM issues
|
FROM issues
|
||||||
WHERE external_ref = ?
|
WHERE external_ref = ?
|
||||||
`, externalRef).Scan(
|
`, externalRef).Scan(
|
||||||
@@ -448,7 +456,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
|||||||
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol,
|
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol,
|
||||||
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
||||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral,
|
&sender, &ephemeral, &pinned,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -506,6 +514,10 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
|
|||||||
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
||||||
issue.Ephemeral = true
|
issue.Ephemeral = true
|
||||||
}
|
}
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
if pinned.Valid && pinned.Int64 != 0 {
|
||||||
|
issue.Pinned = true
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch labels for this issue
|
// Fetch labels for this issue
|
||||||
labels, err := s.GetLabels(ctx, issue.ID)
|
labels, err := s.GetLabels(ctx, issue.ID)
|
||||||
@@ -536,6 +548,8 @@ var allowedUpdateFields = map[string]bool{
|
|||||||
"ephemeral": true,
|
"ephemeral": true,
|
||||||
// NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
|
// NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
|
||||||
// Use AddDependency() to create graph edges instead
|
// Use AddDependency() to create graph edges instead
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
"pinned": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// validatePriority validates a priority value
|
// validatePriority validates a priority value
|
||||||
@@ -1564,7 +1578,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
|
|||||||
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,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
FROM issues
|
FROM issues
|
||||||
%s
|
%s
|
||||||
ORDER BY priority ASC, created_at DESC
|
ORDER BY priority ASC, created_at DESC
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
|
|||||||
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,
|
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
|
||||||
i.sender, i.ephemeral
|
i.sender, i.ephemeral, i.pinned
|
||||||
FROM issues i
|
FROM issues i
|
||||||
WHERE %s
|
WHERE %s
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -130,7 +130,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
|||||||
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,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
FROM issues
|
FROM issues
|
||||||
WHERE status != 'closed'
|
WHERE status != 'closed'
|
||||||
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
|
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
|
||||||
@@ -179,6 +179,8 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
|||||||
// Messaging fields (bd-kwro)
|
// Messaging fields (bd-kwro)
|
||||||
var sender sql.NullString
|
var sender sql.NullString
|
||||||
var ephemeral sql.NullInt64
|
var ephemeral sql.NullInt64
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
var pinned sql.NullInt64
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||||
@@ -187,7 +189,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
|||||||
&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,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral,
|
&sender, &ephemeral, &pinned,
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -244,6 +246,10 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
|||||||
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
||||||
issue.Ephemeral = true
|
issue.Ephemeral = true
|
||||||
}
|
}
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
if pinned.Valid && pinned.Int64 != 0 {
|
||||||
|
issue.Pinned = true
|
||||||
|
}
|
||||||
|
|
||||||
issues = append(issues, &issue)
|
issues = append(issues, &issue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ CREATE TABLE IF NOT EXISTS issues (
|
|||||||
ephemeral INTEGER DEFAULT 0,
|
ephemeral INTEGER DEFAULT 0,
|
||||||
-- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
|
-- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
|
||||||
-- These relationships are now stored in the dependencies table
|
-- These relationships are now stored in the dependencies table
|
||||||
|
-- Protection fields (bd-b2k)
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
|
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue
|
|||||||
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,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
FROM issues
|
FROM issues
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, id)
|
`, id)
|
||||||
@@ -1098,7 +1098,7 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
|
|||||||
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,
|
deleted_at, deleted_by, delete_reason, original_type,
|
||||||
sender, ephemeral
|
sender, ephemeral, pinned
|
||||||
FROM issues
|
FROM issues
|
||||||
%s
|
%s
|
||||||
ORDER BY priority ASC, created_at DESC
|
ORDER BY priority ASC, created_at DESC
|
||||||
@@ -1141,6 +1141,8 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
|
|||||||
// Messaging fields (bd-kwro)
|
// Messaging fields (bd-kwro)
|
||||||
var sender sql.NullString
|
var sender sql.NullString
|
||||||
var ephemeral sql.NullInt64
|
var ephemeral sql.NullInt64
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
var pinned sql.NullInt64
|
||||||
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||||
@@ -1149,7 +1151,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
|
|||||||
&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,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral,
|
&sender, &ephemeral, &pinned,
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -1203,6 +1205,10 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
|
|||||||
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
if ephemeral.Valid && ephemeral.Int64 != 0 {
|
||||||
issue.Ephemeral = true
|
issue.Ephemeral = true
|
||||||
}
|
}
|
||||||
|
// Protection fields (bd-b2k)
|
||||||
|
if pinned.Valid && pinned.Int64 != 0 {
|
||||||
|
issue.Pinned = true
|
||||||
|
}
|
||||||
|
|
||||||
return &issue, nil
|
return &issue, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ type Issue struct {
|
|||||||
Ephemeral bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed
|
Ephemeral bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed
|
||||||
// NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table
|
// NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table
|
||||||
// per Decision 004 (Edge Schema Consolidation). Use dependency API instead.
|
// per Decision 004 (Edge Schema Consolidation). Use dependency API instead.
|
||||||
|
|
||||||
|
// Protection fields (bd-b2k): prevent accidental deletion
|
||||||
|
Pinned bool `json:"pinned,omitempty"` // If true, protected from cleanup/compact
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputeContentHash creates a deterministic hash of the issue's content.
|
// ComputeContentHash creates a deterministic hash of the issue's content.
|
||||||
|
|||||||
Reference in New Issue
Block a user