From f6930eb3999cc6cf775f16eeb7bc9199a18e72be Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 00:46:46 -0800 Subject: [PATCH] feat(list): add --pinned and --no-pinned filter flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to filter issues by their pinned status in bd list command. - Add pinned column to issues table via migration 023 - Add Pinned field to Issue struct and IssueFilter - Update all storage layer queries to include pinned column - Add --pinned flag to show only pinned issues - Add --no-pinned flag to exclude pinned issues - Update RPC layer to forward pinned filter to daemon mode - Add pinned to allowedUpdateFields for bd update support Resolves: beads-p8e 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/list.go | 28 +++++++++++++- internal/rpc/protocol.go | 3 ++ internal/rpc/server_issues_epics.go | 3 ++ internal/storage/sqlite/dependencies.go | 8 +++- internal/storage/sqlite/issues.go | 20 +++++++--- internal/storage/sqlite/labels.go | 2 +- internal/storage/sqlite/migrations.go | 2 + .../sqlite/migrations/023_pinned_column.go | 37 +++++++++++++++++++ internal/storage/sqlite/migrations_test.go | 3 +- internal/storage/sqlite/multirepo.go | 14 ++++--- internal/storage/sqlite/queries.go | 33 ++++++++++++++--- internal/storage/sqlite/ready.go | 12 ++++-- internal/storage/sqlite/schema.go | 3 ++ internal/storage/sqlite/transaction.go | 21 +++++++++-- internal/types/types.go | 6 +++ 15 files changed, 168 insertions(+), 27 deletions(-) create mode 100644 internal/storage/sqlite/migrations/023_pinned_column.go diff --git a/cmd/bd/list.go b/cmd/bd/list.go index e86945b6..b1c2375d 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -128,7 +128,11 @@ var listCmd = &cobra.Command{ // Priority range flags priorityMinStr, _ := cmd.Flags().GetString("priority-min") priorityMaxStr, _ := cmd.Flags().GetString("priority-max") - + + // Pinned filtering flags (bd-p8e) + pinnedFlag, _ := cmd.Flags().GetBool("pinned") + noPinnedFlag, _ := cmd.Flags().GetBool("no-pinned") + // Use global jsonOutput set by PersistentPreRun // Normalize labels: trim, dedupe, remove empty @@ -265,6 +269,19 @@ var listCmd = &cobra.Command{ filter.PriorityMax = &priorityMax } + // Pinned filtering (bd-p8e): --pinned and --no-pinned are mutually exclusive + if pinnedFlag && noPinnedFlag { + fmt.Fprintf(os.Stderr, "Error: --pinned and --no-pinned are mutually exclusive\n") + os.Exit(1) + } + if pinnedFlag { + pinned := true + filter.Pinned = &pinned + } else if noPinnedFlag { + pinned := false + filter.Pinned = &pinned + } + // Check database freshness before reading (bd-2q6d, bd-c4rq) // Skip check when using daemon (daemon auto-imports on staleness) ctx := rootCtx @@ -340,6 +357,9 @@ var listCmd = &cobra.Command{ listArgs.PriorityMin = filter.PriorityMin listArgs.PriorityMax = filter.PriorityMax + // Pinned filtering (bd-p8e) + listArgs.Pinned = filter.Pinned + resp, err := daemonClient.List(listArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -552,7 +572,11 @@ func init() { // Priority ranges listCmd.Flags().String("priority-min", "", "Filter by minimum priority (inclusive, 0-4 or P0-P4)") listCmd.Flags().String("priority-max", "", "Filter by maximum priority (inclusive, 0-4 or P0-P4)") - + + // Pinned filtering (bd-p8e) + listCmd.Flags().Bool("pinned", false, "Show only pinned issues") + listCmd.Flags().Bool("no-pinned", false, "Exclude pinned issues") + // Note: --json flag is defined as a persistent flag in main.go, not here rootCmd.AddCommand(listCmd) } diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index aae6aed8..bb692d8d 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -154,6 +154,9 @@ type ListArgs struct { // Priority range PriorityMin *int `json:"priority_min,omitempty"` PriorityMax *int `json:"priority_max,omitempty"` + + // Pinned filtering (bd-p8e) + Pinned *bool `json:"pinned,omitempty"` } // CountArgs represents arguments for the count operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index aaeddf51..25b8d844 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -694,6 +694,9 @@ func (s *Server) handleList(req *Request) Response { filter.PriorityMin = listArgs.PriorityMin filter.PriorityMax = listArgs.PriorityMax + // Pinned filtering (bd-p8e) + filter.Pinned = listArgs.Pinned + // Guard against excessive ID lists to avoid SQLite parameter limits const maxIDs = 1000 if len(filter.IDs) > maxIDs { diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index d55543bf..38b26aad 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -714,6 +714,8 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type // Messaging fields (bd-kwro) var sender sql.NullString var ephemeral sql.NullInt64 + // Pinned flag (bd-p8e) + var pinned sql.NullInt64 err := rows.Scan( &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.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, + &sender, &ephemeral, &pinned, ) if err != nil { 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 { issue.Ephemeral = true } + // Pinned flag (bd-p8e) + if pinned.Valid && pinned.Int64 != 0 { + issue.Pinned = true + } issues = append(issues, &issue) issueIDs = append(issueIDs, issue.ID) diff --git a/internal/storage/sqlite/issues.go b/internal/storage/sqlite/issues.go index 23ec183a..c3e4bcfb 100644 --- a/internal/storage/sqlite/issues.go +++ b/internal/storage/sqlite/issues.go @@ -31,6 +31,10 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error if issue.Ephemeral { ephemeral = 1 } + pinned := 0 + if issue.Pinned { + pinned = 1 + } _, err := conn.ExecContext(ctx, ` INSERT OR IGNORE INTO issues ( @@ -38,8 +42,8 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sender, ephemeral, pinned + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -47,7 +51,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, + issue.Sender, ephemeral, pinned, ) if err != nil { // INSERT OR IGNORE should handle duplicates, but driver may still return error @@ -68,8 +72,8 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sender, ephemeral, pinned + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -86,6 +90,10 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er if issue.Ephemeral { ephemeral = 1 } + pinned := 0 + if issue.Pinned { + pinned = 1 + } _, err = stmt.ExecContext(ctx, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, @@ -94,7 +102,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, + issue.Sender, ephemeral, pinned, ) if err != nil { // INSERT OR IGNORE should handle duplicates, but driver may still return error diff --git a/internal/storage/sqlite/labels.go b/internal/storage/sqlite/labels.go index cc11ed3c..c07a20b1 100644 --- a/internal/storage/sqlite/labels.go +++ b/internal/storage/sqlite/labels.go @@ -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.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.sender, i.ephemeral + i.sender, i.ephemeral, i.pinned FROM issues i JOIN labels l ON i.id = l.issue_id WHERE l.label = ? diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index b84f6409..612a96a4 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -39,6 +39,7 @@ var migrationsList = []Migration{ {"edge_consolidation", migrations.MigrateEdgeConsolidation}, {"migrate_edge_fields", migrations.MigrateEdgeFields}, {"drop_edge_columns", migrations.MigrateDropEdgeColumns}, + {"pinned_column", migrations.MigratePinnedColumn}, } // 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)", "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)", + "pinned_column": "Adds pinned column for flagging important issues (bd-p8e)", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/023_pinned_column.go b/internal/storage/sqlite/migrations/023_pinned_column.go new file mode 100644 index 00000000..4a0c8e63 --- /dev/null +++ b/internal/storage/sqlite/migrations/023_pinned_column.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigratePinnedColumn adds the pinned column to the issues table. +// Pinned issues are visually marked and can be filtered with --pinned/--no-pinned flags. +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 partial index for efficient pinned issue 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 +} diff --git a/internal/storage/sqlite/migrations_test.go b/internal/storage/sqlite/migrations_test.go index 1e02b3db..88f6da59 100644 --- a/internal/storage/sqlite/migrations_test.go +++ b/internal/storage/sqlite/migrations_test.go @@ -485,13 +485,14 @@ func TestMigrateContentHashColumn(t *testing.T) { original_type TEXT DEFAULT '', sender TEXT DEFAULT '', ephemeral INTEGER DEFAULT 0, + pinned INTEGER DEFAULT 0, replies_to TEXT DEFAULT '', relates_to TEXT DEFAULT '', duplicate_of TEXT DEFAULT '', superseded_by TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ); - INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', 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; `) if err != nil { diff --git a/internal/storage/sqlite/multirepo.go b/internal/storage/sqlite/multirepo.go index 8e37c5d4..86861bdb 100644 --- a/internal/storage/sqlite/multirepo.go +++ b/internal/storage/sqlite/multirepo.go @@ -261,6 +261,10 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * if issue.Ephemeral { ephemeral = 1 } + pinned := 0 + if issue.Pinned { + pinned = 1 + } if err == sql.ErrNoRows { // Issue doesn't exist - insert it @@ -270,8 +274,8 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + sender, ephemeral, pinned + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -279,7 +283,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.CloseReason, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, + issue.Sender, ephemeral, pinned, ) if err != nil { return fmt.Errorf("failed to insert issue: %w", err) @@ -303,7 +307,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue_type = ?, assignee = ?, estimated_minutes = ?, updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?, deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?, - sender = ?, ephemeral = ? + sender = ?, ephemeral = ?, pinned = ? WHERE id = ? `, issue.ContentHash, issue.Title, issue.Description, issue.Design, @@ -311,7 +315,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * issue.IssueType, issue.Assignee, issue.EstimatedMinutes, issue.UpdatedAt, issue.ClosedAt, issue.ExternalRef, issue.SourceRepo, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType, - issue.Sender, ephemeral, + issue.Sender, ephemeral, pinned, issue.ID, ) if err != nil { diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 5939d379..51964c8e 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -248,6 +248,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, // Messaging fields (bd-kwro) var sender sql.NullString var ephemeral sql.NullInt64 + // Pinned flag (bd-p8e) + var pinned sql.NullInt64 var contentHash 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, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral + sender, ephemeral, pinned FROM issues WHERE id = ? `, id).Scan( @@ -267,7 +269,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, + &sender, &ephemeral, &pinned, ) 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 { issue.Ephemeral = true } + // Pinned flag (bd-p8e) + if pinned.Valid && pinned.Int64 != 0 { + issue.Pinned = true + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -431,6 +437,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s // Messaging fields (bd-kwro) var sender sql.NullString var ephemeral sql.NullInt64 + // Pinned flag (bd-p8e) + var pinned sql.NullInt64 err := s.db.QueryRowContext(ctx, ` 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, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral + sender, ephemeral, pinned FROM issues WHERE external_ref = ? `, externalRef).Scan( @@ -448,7 +456,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRefCol, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, + &sender, &ephemeral, &pinned, ) if err == sql.ErrNoRows { @@ -506,6 +514,10 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s if ephemeral.Valid && ephemeral.Int64 != 0 { issue.Ephemeral = true } + // Pinned flag (bd-p8e) + if pinned.Valid && pinned.Int64 != 0 { + issue.Pinned = true + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -536,6 +548,8 @@ var allowedUpdateFields = map[string]bool{ "ephemeral": true, // NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004 // Use AddDependency() to create graph edges instead + // Pinned flag (bd-p8e) + "pinned": true, } // validatePriority validates a priority value @@ -1547,6 +1561,15 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t } } + // Pinned filtering (bd-p8e) + if filter.Pinned != nil { + if *filter.Pinned { + whereClauses = append(whereClauses, "pinned = 1") + } else { + whereClauses = append(whereClauses, "(pinned = 0 OR pinned IS NULL)") + } + } + whereSQL := "" if len(whereClauses) > 0 { whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") @@ -1564,7 +1587,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral + sender, ephemeral, pinned FROM issues %s ORDER BY priority ASC, created_at DESC diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index 17a16b64..583c2e4a 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -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.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.sender, i.ephemeral + i.sender, i.ephemeral, i.pinned FROM issues i WHERE %s 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, compaction_level, compacted_at, compacted_at_commit, original_size, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral + sender, ephemeral, pinned FROM issues WHERE status != 'closed' 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) var sender sql.NullString var ephemeral sql.NullInt64 + // Pinned flag (bd-p8e) + var pinned sql.NullInt64 err := rows.Scan( &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, &compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, + &sender, &ephemeral, &pinned, ) if err != nil { 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 { issue.Ephemeral = true } + // Pinned flag (bd-p8e) + if pinned.Valid && pinned.Int64 != 0 { + issue.Pinned = true + } issues = append(issues, &issue) } diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 750e3c33..3d711bad 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS issues ( -- Messaging fields (bd-kwro) sender TEXT DEFAULT '', ephemeral INTEGER DEFAULT 0, + -- Pinned flag (bd-p8e) + pinned INTEGER DEFAULT 0, -- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004 -- These relationships are now stored in the dependencies table CHECK ((status = 'closed') = (closed_at IS NOT NULL)) @@ -39,6 +41,7 @@ CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status); CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority); CREATE INDEX IF NOT EXISTS idx_issues_assignee ON issues(assignee); CREATE INDEX IF NOT EXISTS idx_issues_created_at ON issues(created_at); +CREATE INDEX IF NOT EXISTS idx_issues_pinned ON issues(pinned) WHERE pinned = 1; -- Note: idx_issues_external_ref is created in migrations/002_external_ref_column.go -- Dependencies table (edge schema - Decision 004) diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index abfa933d..f7d6d7ac 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -306,7 +306,7 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral + sender, ephemeral, pinned FROM issues WHERE id = ? `, id) @@ -1080,6 +1080,15 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter } } + // Pinned filtering (bd-p8e) + if filter.Pinned != nil { + if *filter.Pinned { + whereClauses = append(whereClauses, "pinned = 1") + } else { + whereClauses = append(whereClauses, "(pinned = 0 OR pinned IS NULL)") + } + } + whereSQL := "" if len(whereClauses) > 0 { whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") @@ -1098,7 +1107,7 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter created_at, updated_at, closed_at, external_ref, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, - sender, ephemeral + sender, ephemeral, pinned FROM issues %s ORDER BY priority ASC, created_at DESC @@ -1141,6 +1150,8 @@ func scanIssueRow(row scanner) (*types.Issue, error) { // Messaging fields (bd-kwro) var sender sql.NullString var ephemeral sql.NullInt64 + // Pinned flag (bd-p8e) + var pinned sql.NullInt64 err := row.Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, @@ -1149,7 +1160,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &ephemeral, + &sender, &ephemeral, &pinned, ) if err != nil { return nil, fmt.Errorf("failed to scan issue: %w", err) @@ -1203,6 +1214,10 @@ func scanIssueRow(row scanner) (*types.Issue, error) { if ephemeral.Valid && ephemeral.Int64 != 0 { issue.Ephemeral = true } + // Pinned flag (bd-p8e) + if pinned.Valid && pinned.Int64 != 0 { + issue.Pinned = true + } return &issue, nil } diff --git a/internal/types/types.go b/internal/types/types.go index a87c00ea..09a08068 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -45,6 +45,9 @@ type Issue struct { Ephemeral bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed // NOTE: RepliesTo, RelatesTo, DuplicateOf, SupersededBy moved to dependencies table // per Decision 004 (Edge Schema Consolidation). Use dependency API instead. + + // Pinned flag (bd-p8e): mark important issues + Pinned bool `json:"pinned,omitempty"` // Pinned issues are visually marked } // ComputeContentHash creates a deterministic hash of the issue's content. @@ -432,6 +435,9 @@ type IssueFilter struct { // Ephemeral filtering (bd-kwro.9) Ephemeral *bool // Filter by ephemeral flag (nil = any, true = only ephemeral, false = only non-ephemeral) + + // Pinned filtering (bd-p8e) + Pinned *bool // Filter by pinned flag (nil = any, true = only pinned, false = only non-pinned) } // SortPolicy determines how ready work is ordered