feat(list): add --pinned and --no-pinned filter flags

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 00:46:46 -08:00
parent 9dc34da64a
commit f6930eb399
15 changed files with 168 additions and 27 deletions
+24
View File
@@ -129,6 +129,10 @@ var listCmd = &cobra.Command{
priorityMinStr, _ := cmd.Flags().GetString("priority-min") priorityMinStr, _ := cmd.Flags().GetString("priority-min")
priorityMaxStr, _ := cmd.Flags().GetString("priority-max") 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 // Use global jsonOutput set by PersistentPreRun
// Normalize labels: trim, dedupe, remove empty // Normalize labels: trim, dedupe, remove empty
@@ -265,6 +269,19 @@ var listCmd = &cobra.Command{
filter.PriorityMax = &priorityMax 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) // Check database freshness before reading (bd-2q6d, bd-c4rq)
// Skip check when using daemon (daemon auto-imports on staleness) // Skip check when using daemon (daemon auto-imports on staleness)
ctx := rootCtx ctx := rootCtx
@@ -340,6 +357,9 @@ var listCmd = &cobra.Command{
listArgs.PriorityMin = filter.PriorityMin listArgs.PriorityMin = filter.PriorityMin
listArgs.PriorityMax = filter.PriorityMax listArgs.PriorityMax = filter.PriorityMax
// Pinned filtering (bd-p8e)
listArgs.Pinned = filter.Pinned
resp, err := daemonClient.List(listArgs) resp, err := daemonClient.List(listArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -553,6 +573,10 @@ func init() {
listCmd.Flags().String("priority-min", "", "Filter by minimum priority (inclusive, 0-4 or P0-P4)") 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)") 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 // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(listCmd) rootCmd.AddCommand(listCmd)
} }
+3
View File
@@ -154,6 +154,9 @@ type ListArgs struct {
// Priority range // Priority range
PriorityMin *int `json:"priority_min,omitempty"` PriorityMin *int `json:"priority_min,omitempty"`
PriorityMax *int `json:"priority_max,omitempty"` PriorityMax *int `json:"priority_max,omitempty"`
// Pinned filtering (bd-p8e)
Pinned *bool `json:"pinned,omitempty"`
} }
// CountArgs represents arguments for the count operation // CountArgs represents arguments for the count operation
+3
View File
@@ -694,6 +694,9 @@ func (s *Server) handleList(req *Request) Response {
filter.PriorityMin = listArgs.PriorityMin filter.PriorityMin = listArgs.PriorityMin
filter.PriorityMax = listArgs.PriorityMax filter.PriorityMax = listArgs.PriorityMax
// Pinned filtering (bd-p8e)
filter.Pinned = listArgs.Pinned
// Guard against excessive ID lists to avoid SQLite parameter limits // Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000 const maxIDs = 1000
if len(filter.IDs) > maxIDs { if len(filter.IDs) > maxIDs {
+7 -1
View File
@@ -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
// Pinned flag (bd-p8e)
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
} }
// Pinned flag (bd-p8e)
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)
+14 -6
View File
@@ -31,6 +31,10 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
if issue.Ephemeral { if issue.Ephemeral {
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 (
@@ -38,8 +42,8 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
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 +51,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 +72,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)
@@ -86,6 +90,10 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
if issue.Ephemeral { if issue.Ephemeral {
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,
@@ -94,7 +102,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
+1 -1
View File
@@ -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 = ?
+2
View File
@@ -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 for flagging important issues (bd-p8e)",
} }
if desc, ok := descriptions[name]; ok { if desc, ok := descriptions[name]; ok {
@@ -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
}
+2 -1
View File
@@ -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 {
+9 -5
View File
@@ -261,6 +261,10 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
if issue.Ephemeral { if issue.Ephemeral {
ephemeral = 1 ephemeral = 1
} }
pinned := 0
if issue.Pinned {
pinned = 1
}
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// Issue doesn't exist - insert it // 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, 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,
@@ -279,7 +283,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
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, issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, ephemeral, issue.Sender, ephemeral, pinned,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to insert issue: %w", err) 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 = ?, 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 = ?, deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?,
sender = ?, ephemeral = ? sender = ?, ephemeral = ?, pinned = ?
WHERE id = ? WHERE id = ?
`, `,
issue.ContentHash, issue.Title, issue.Description, issue.Design, 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.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.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, ephemeral, issue.Sender, ephemeral, pinned,
issue.ID, issue.ID,
) )
if err != nil { if err != nil {
+28 -5
View File
@@ -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
// Pinned flag (bd-p8e)
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
} }
// Pinned flag (bd-p8e)
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
// Pinned flag (bd-p8e)
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
} }
// Pinned flag (bd-p8e)
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
// Pinned flag (bd-p8e)
"pinned": true,
} }
// validatePriority validates a priority value // 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 := "" whereSQL := ""
if len(whereClauses) > 0 { if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") 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, 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
+9 -3
View File
@@ -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
// Pinned flag (bd-p8e)
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
} }
// Pinned flag (bd-p8e)
if pinned.Valid && pinned.Int64 != 0 {
issue.Pinned = true
}
issues = append(issues, &issue) issues = append(issues, &issue)
} }
+3
View File
@@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS issues (
-- Messaging fields (bd-kwro) -- Messaging fields (bd-kwro)
sender TEXT DEFAULT '', sender TEXT DEFAULT '',
ephemeral INTEGER DEFAULT 0, 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 -- 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
CHECK ((status = 'closed') = (closed_at IS NOT NULL)) 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_priority ON issues(priority);
CREATE INDEX IF NOT EXISTS idx_issues_assignee ON issues(assignee); 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_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 -- Note: idx_issues_external_ref is created in migrations/002_external_ref_column.go
-- Dependencies table (edge schema - Decision 004) -- Dependencies table (edge schema - Decision 004)
+18 -3
View File
@@ -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)
@@ -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 := "" whereSQL := ""
if len(whereClauses) > 0 { if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") 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, 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 +1150,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
// Pinned flag (bd-p8e)
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 +1160,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 +1214,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
} }
// Pinned flag (bd-p8e)
if pinned.Valid && pinned.Int64 != 0 {
issue.Pinned = true
}
return &issue, nil return &issue, nil
} }
+6
View File
@@ -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.
// 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. // ComputeContentHash creates a deterministic hash of the issue's content.
@@ -432,6 +435,9 @@ type IssueFilter struct {
// Ephemeral filtering (bd-kwro.9) // Ephemeral filtering (bd-kwro.9)
Ephemeral *bool // Filter by ephemeral flag (nil = any, true = only ephemeral, false = only non-ephemeral) 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 // SortPolicy determines how ready work is ordered