diff --git a/cmd/bd/audit.go b/cmd/bd/audit.go index 2003842a..567a9319 100644 --- a/cmd/bd/audit.go +++ b/cmd/bd/audit.go @@ -160,6 +160,9 @@ func init() { auditLabelCmd.Flags().StringVar(&auditLabelValue, "label", "", `Label value (e.g. "good" or "bad")`) auditLabelCmd.Flags().StringVar(&auditLabelReason, "reason", "", "Reason for label") + // Issue ID completions + auditCmd.ValidArgsFunction = issueIDCompletion + auditCmd.AddCommand(auditRecordCmd) auditCmd.AddCommand(auditLabelCmd) rootCmd.AddCommand(auditCmd) diff --git a/cmd/bd/comments.go b/cmd/bd/comments.go index 5bbbcd3d..8e6ac7bc 100644 --- a/cmd/bd/comments.go +++ b/cmd/bd/comments.go @@ -218,11 +218,16 @@ func init() { commentsCmd.AddCommand(commentsAddCmd) commentsAddCmd.Flags().StringP("file", "f", "", "Read comment text from file") commentsAddCmd.Flags().StringP("author", "a", "", "Add author to comment") - + // Add the same flags to the alias commentCmd.Flags().StringP("file", "f", "", "Read comment text from file") commentCmd.Flags().StringP("author", "a", "", "Add author to comment") - + + // Issue ID completions + commentsCmd.ValidArgsFunction = issueIDCompletion + commentsAddCmd.ValidArgsFunction = issueIDCompletion + commentCmd.ValidArgsFunction = issueIDCompletion + rootCmd.AddCommand(commentsCmd) rootCmd.AddCommand(commentCmd) } diff --git a/cmd/bd/completions.go b/cmd/bd/completions.go index cedc5e0c..6f860a7c 100644 --- a/cmd/bd/completions.go +++ b/cmd/bd/completions.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "path/filepath" - "strings" "time" "github.com/spf13/cobra" @@ -51,8 +50,10 @@ func issueIDCompletion(cmd *cobra.Command, args []string, toComplete string) ([] defer currentStore.Close() } - // Use SearchIssues with empty query and default filter to get all issues - filter := types.IssueFilter{} + // Use SearchIssues with IDPrefix filter to efficiently query matching issues + filter := types.IssueFilter{ + IDPrefix: toComplete, // Filter at database level for better performance + } issues, err := currentStore.SearchIssues(ctx, "", filter) if err != nil { // If we can't list issues, return empty completion @@ -62,11 +63,6 @@ func issueIDCompletion(cmd *cobra.Command, args []string, toComplete string) ([] // Build completion list completions := make([]string, 0, len(issues)) for _, issue := range issues { - // Filter based on what's already typed - if toComplete != "" && !strings.HasPrefix(issue.ID, toComplete) { - continue - } - // Format: ID\tTitle (shown during completion) completions = append(completions, fmt.Sprintf("%s\t%s", issue.ID, issue.Title)) } diff --git a/cmd/bd/delete.go b/cmd/bd/delete.go index ffc943d2..9acf7590 100644 --- a/cmd/bd/delete.go +++ b/cmd/bd/delete.go @@ -833,5 +833,6 @@ func init() { deleteCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues") deleteCmd.Flags().Bool("hard", false, "Permanently delete (skip tombstone, cannot be recovered via sync)") deleteCmd.Flags().String("reason", "", "Reason for deletion (stored in tombstone for audit trail)") + deleteCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(deleteCmd) } diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 8b00229a..7637d369 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -1167,6 +1167,12 @@ func init() { depListCmd.Flags().String("direction", "down", "Direction: 'down' (dependencies), 'up' (dependents)") depListCmd.Flags().StringP("type", "t", "", "Filter by dependency type (e.g., tracks, blocks, parent-child)") + // Issue ID completions for dep subcommands + depAddCmd.ValidArgsFunction = issueIDCompletion + depRemoveCmd.ValidArgsFunction = issueIDCompletion + depListCmd.ValidArgsFunction = issueIDCompletion + depTreeCmd.ValidArgsFunction = issueIDCompletion + depCmd.AddCommand(depAddCmd) depCmd.AddCommand(depRemoveCmd) depCmd.AddCommand(depListCmd) diff --git a/cmd/bd/duplicate.go b/cmd/bd/duplicate.go index 51f7574c..5b635aab 100644 --- a/cmd/bd/duplicate.go +++ b/cmd/bd/duplicate.go @@ -50,10 +50,12 @@ var ( func init() { duplicateCmd.Flags().StringVar(&duplicateOf, "of", "", "Canonical issue ID (required)") _ = duplicateCmd.MarkFlagRequired("of") // Only fails if flag missing (caught in tests) + duplicateCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(duplicateCmd) supersedeCmd.Flags().StringVar(&supersededWith, "with", "", "Replacement issue ID (required)") _ = supersedeCmd.MarkFlagRequired("with") // Only fails if flag missing (caught in tests) + supersedeCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(supersedeCmd) } diff --git a/cmd/bd/gate.go b/cmd/bd/gate.go index 8e3f0914..b6f669dd 100644 --- a/cmd/bd/gate.go +++ b/cmd/bd/gate.go @@ -871,6 +871,11 @@ func init() { gateCheckCmd.Flags().BoolP("escalate", "e", false, "Escalate failed/expired gates") gateCheckCmd.Flags().IntP("limit", "l", 100, "Limit results (default 100)") + // Issue ID completions + gateShowCmd.ValidArgsFunction = issueIDCompletion + gateResolveCmd.ValidArgsFunction = issueIDCompletion + gateAddWaiterCmd.ValidArgsFunction = issueIDCompletion + // Add subcommands gateCmd.AddCommand(gateListCmd) gateCmd.AddCommand(gateShowCmd) diff --git a/cmd/bd/graph.go b/cmd/bd/graph.go index d4e585dd..6fb23cdc 100644 --- a/cmd/bd/graph.go +++ b/cmd/bd/graph.go @@ -117,6 +117,7 @@ Colors indicate status: } func init() { + graphCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(graphCmd) } diff --git a/cmd/bd/label.go b/cmd/bd/label.go index a56fcbfd..94febc55 100644 --- a/cmd/bd/label.go +++ b/cmd/bd/label.go @@ -314,6 +314,11 @@ var labelListAllCmd = &cobra.Command{ }, } func init() { + // Issue ID completions + labelAddCmd.ValidArgsFunction = issueIDCompletion + labelRemoveCmd.ValidArgsFunction = issueIDCompletion + labelListCmd.ValidArgsFunction = issueIDCompletion + labelCmd.AddCommand(labelAddCmd) labelCmd.AddCommand(labelRemoveCmd) labelCmd.AddCommand(labelListCmd) diff --git a/cmd/bd/move.go b/cmd/bd/move.go index 496b24c9..00487ed6 100644 --- a/cmd/bd/move.go +++ b/cmd/bd/move.go @@ -276,5 +276,6 @@ func init() { moveCmd.Flags().String("to", "", "Target rig or prefix (required)") moveCmd.Flags().Bool("keep-open", false, "Keep the source issue open (don't close it)") moveCmd.Flags().Bool("skip-deps", false, "Skip dependency remapping") + moveCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(moveCmd) } diff --git a/cmd/bd/refile.go b/cmd/bd/refile.go index 9df057a8..02a3cece 100644 --- a/cmd/bd/refile.go +++ b/cmd/bd/refile.go @@ -160,5 +160,6 @@ Examples: func init() { refileCmd.Flags().Bool("keep-open", false, "Keep the source issue open (don't close it)") + refileCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(refileCmd) } diff --git a/cmd/bd/relate.go b/cmd/bd/relate.go index 7610559b..1830f211 100644 --- a/cmd/bd/relate.go +++ b/cmd/bd/relate.go @@ -41,6 +41,10 @@ Example: } func init() { + // Issue ID completions + relateCmd.ValidArgsFunction = issueIDCompletion + unrelateCmd.ValidArgsFunction = issueIDCompletion + // Add as subcommands of dep depCmd.AddCommand(relateCmd) depCmd.AddCommand(unrelateCmd) diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 3b37fe87..f52de112 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -620,6 +620,11 @@ func (m *MemoryStorage) SearchIssues(ctx context.Context, query string, filter t } } + // ID prefix filtering (for shell completion) + if filter.IDPrefix != "" && !strings.HasPrefix(issue.ID, filter.IDPrefix) { + continue + } + // Parent filtering (bd-yqhh): filter children by parent issue if filter.ParentID != nil { isChild := false diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 9d216254..13fcaef9 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -1836,6 +1836,12 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", "))) } + // ID prefix filtering (for shell completion) + if filter.IDPrefix != "" { + whereClauses = append(whereClauses, "id LIKE ?") + args = append(args, filter.IDPrefix+"%") + } + // Wisp filtering if filter.Ephemeral != nil { if *filter.Ephemeral { diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index 06fbcab1..9f2a6ab5 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -1181,6 +1181,12 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", "))) } + // ID prefix filtering (for shell completion) + if filter.IDPrefix != "" { + whereClauses = append(whereClauses, "id LIKE ?") + args = append(args, filter.IDPrefix+"%") + } + // Wisp filtering if filter.Ephemeral != nil { if *filter.Ephemeral { diff --git a/internal/types/types.go b/internal/types/types.go index b6fa493b..e958b985 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -758,7 +758,8 @@ type IssueFilter struct { Labels []string // AND semantics: issue must have ALL these labels LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels TitleSearch string - IDs []string // Filter by specific issue IDs + IDs []string // Filter by specific issue IDs + IDPrefix string // Filter by ID prefix (for shell completion) Limit int // Pattern matching