feat(completion): add dynamic shell completions for issue IDs (#935)
Add intelligent shell completions that query the database to provide issue ID suggestions with titles for commands that take IDs as arguments. Changes: - Add issueIDCompletion function that queries storage for all issues - Register completion for show, update, close, edit, defer, undefer, reopen - Add comprehensive test suite with 3 test cases - Completions display ID with title as description (ID\tTitle format) The completion function opens the database (read-only) and filters issues based on the partially typed prefix, providing a better UX for commands that require issue IDs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -364,5 +364,6 @@ func init() {
|
||||
closeCmd.Flags().Bool("no-auto", false, "With --continue, show next step but don't claim it")
|
||||
closeCmd.Flags().Bool("suggest-next", false, "Show newly unblocked issues after closing")
|
||||
closeCmd.Flags().String("session", "", "Claude Code session ID (or set CLAUDE_SESSION_ID env var)")
|
||||
closeCmd.ValidArgsFunction = issueIDCompletion
|
||||
rootCmd.AddCommand(closeCmd)
|
||||
}
|
||||
|
||||
75
cmd/bd/completions.go
Normal file
75
cmd/bd/completions.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// issueIDCompletion provides shell completion for issue IDs by querying the storage
|
||||
// and returning a list of IDs with their titles as descriptions
|
||||
func issueIDCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
// Initialize storage if not already initialized
|
||||
ctx := context.Background()
|
||||
if rootCtx != nil {
|
||||
ctx = rootCtx
|
||||
}
|
||||
|
||||
// Get database path - use same logic as in PersistentPreRun
|
||||
currentDBPath := dbPath
|
||||
if currentDBPath == "" {
|
||||
// Try to find database path
|
||||
foundDB := beads.FindDatabasePath()
|
||||
if foundDB != "" {
|
||||
currentDBPath = foundDB
|
||||
} else {
|
||||
// Default path
|
||||
currentDBPath = filepath.Join(".beads", beads.CanonicalDatabaseName)
|
||||
}
|
||||
}
|
||||
|
||||
// Open database if store is not initialized
|
||||
currentStore := store
|
||||
if currentStore == nil {
|
||||
var err error
|
||||
timeout := 30 * time.Second
|
||||
if lockTimeout > 0 {
|
||||
timeout = lockTimeout
|
||||
}
|
||||
currentStore, err = sqlite.NewReadOnlyWithTimeout(ctx, currentDBPath, timeout)
|
||||
if err != nil {
|
||||
// If we can't open database, return empty completion
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
defer currentStore.Close()
|
||||
}
|
||||
|
||||
// Use SearchIssues with empty query and default filter to get all issues
|
||||
filter := types.IssueFilter{}
|
||||
issues, err := currentStore.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
// If we can't list issues, return empty completion
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
242
cmd/bd/completions_test.go
Normal file
242
cmd/bd/completions_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/storage/memory"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestIssueIDCompletion(t *testing.T) {
|
||||
// Save original store and restore after test
|
||||
originalStore := store
|
||||
originalRootCtx := rootCtx
|
||||
defer func() {
|
||||
store = originalStore
|
||||
rootCtx = originalRootCtx
|
||||
}()
|
||||
|
||||
// Set up test context
|
||||
ctx := context.Background()
|
||||
rootCtx = ctx
|
||||
|
||||
// Create in-memory store for testing
|
||||
memStore := memory.New("")
|
||||
store = memStore
|
||||
|
||||
// Create test issues
|
||||
testIssues := []*types.Issue{
|
||||
{
|
||||
ID: "bd-abc1",
|
||||
Title: "Test issue 1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
},
|
||||
{
|
||||
ID: "bd-abc2",
|
||||
Title: "Test issue 2",
|
||||
Status: types.StatusInProgress,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeBug,
|
||||
},
|
||||
{
|
||||
ID: "bd-xyz1",
|
||||
Title: "Another test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeFeature,
|
||||
},
|
||||
{
|
||||
ID: "bd-xyz2",
|
||||
Title: "Yet another test",
|
||||
Status: types.StatusClosed,
|
||||
Priority: 3,
|
||||
IssueType: types.TypeTask,
|
||||
ClosedAt: &[]time.Time{time.Now()}[0],
|
||||
},
|
||||
}
|
||||
|
||||
for _, issue := range testIssues {
|
||||
if err := memStore.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create test issue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
toComplete string
|
||||
expectedCount int
|
||||
shouldContain []string
|
||||
shouldNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "Empty prefix returns all issues",
|
||||
toComplete: "",
|
||||
expectedCount: 4,
|
||||
shouldContain: []string{"bd-abc1", "bd-abc2", "bd-xyz1", "bd-xyz2"},
|
||||
},
|
||||
{
|
||||
name: "Prefix 'bd-a' returns matching issues",
|
||||
toComplete: "bd-a",
|
||||
expectedCount: 2,
|
||||
shouldContain: []string{"bd-abc1", "bd-abc2"},
|
||||
shouldNotContain: []string{"bd-xyz1", "bd-xyz2"},
|
||||
},
|
||||
{
|
||||
name: "Prefix 'bd-abc' returns matching issues",
|
||||
toComplete: "bd-abc",
|
||||
expectedCount: 2,
|
||||
shouldContain: []string{"bd-abc1", "bd-abc2"},
|
||||
shouldNotContain: []string{"bd-xyz1", "bd-xyz2"},
|
||||
},
|
||||
{
|
||||
name: "Prefix 'bd-abc1' returns exact match",
|
||||
toComplete: "bd-abc1",
|
||||
expectedCount: 1,
|
||||
shouldContain: []string{"bd-abc1"},
|
||||
shouldNotContain: []string{"bd-abc2", "bd-xyz1", "bd-xyz2"},
|
||||
},
|
||||
{
|
||||
name: "Prefix 'bd-xyz' returns matching issues",
|
||||
toComplete: "bd-xyz",
|
||||
expectedCount: 2,
|
||||
shouldContain: []string{"bd-xyz1", "bd-xyz2"},
|
||||
shouldNotContain: []string{"bd-abc1", "bd-abc2"},
|
||||
},
|
||||
{
|
||||
name: "Non-matching prefix returns empty",
|
||||
toComplete: "bd-zzz",
|
||||
expectedCount: 0,
|
||||
shouldContain: []string{},
|
||||
shouldNotContain: []string{"bd-abc1", "bd-abc2", "bd-xyz1", "bd-xyz2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a dummy command (not actually used by the function)
|
||||
cmd := &cobra.Command{}
|
||||
args := []string{}
|
||||
|
||||
// Call the completion function
|
||||
completions, directive := issueIDCompletion(cmd, args, tt.toComplete)
|
||||
|
||||
// Check directive
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("Expected directive NoFileComp (4), got %d", directive)
|
||||
}
|
||||
|
||||
// Check count
|
||||
if len(completions) != tt.expectedCount {
|
||||
t.Errorf("Expected %d completions, got %d", tt.expectedCount, len(completions))
|
||||
}
|
||||
|
||||
// Check that expected IDs are present
|
||||
for _, expectedID := range tt.shouldContain {
|
||||
found := false
|
||||
for _, completion := range completions {
|
||||
// Completion format is "ID\tTitle"
|
||||
if len(completion) > 0 && completion[:len(expectedID)] == expectedID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected completion to contain '%s', but it was not found", expectedID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that unexpected IDs are NOT present
|
||||
for _, unexpectedID := range tt.shouldNotContain {
|
||||
for _, completion := range completions {
|
||||
if len(completion) > 0 && completion[:len(unexpectedID)] == unexpectedID {
|
||||
t.Errorf("Did not expect completion to contain '%s', but it was found", unexpectedID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify format: each completion should be "ID\tTitle"
|
||||
for _, completion := range completions {
|
||||
if len(completion) == 0 {
|
||||
t.Errorf("Got empty completion string")
|
||||
continue
|
||||
}
|
||||
// Check that it contains a tab character
|
||||
foundTab := false
|
||||
for _, c := range completion {
|
||||
if c == '\t' {
|
||||
foundTab = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundTab {
|
||||
t.Errorf("Completion '%s' doesn't contain tab separator", completion)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueIDCompletion_NoStore(t *testing.T) {
|
||||
// Save original store and restore after test
|
||||
originalStore := store
|
||||
originalDBPath := dbPath
|
||||
defer func() {
|
||||
store = originalStore
|
||||
dbPath = originalDBPath
|
||||
}()
|
||||
|
||||
// Set store to nil and dbPath to non-existent path
|
||||
store = nil
|
||||
dbPath = "/nonexistent/path/to/database.db"
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
args := []string{}
|
||||
|
||||
completions, directive := issueIDCompletion(cmd, args, "")
|
||||
|
||||
// Should return empty completions when store is nil and dbPath is invalid
|
||||
if len(completions) != 0 {
|
||||
t.Errorf("Expected 0 completions when store is nil and dbPath is invalid, got %d", len(completions))
|
||||
}
|
||||
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("Expected directive NoFileComp (4), got %d", directive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueIDCompletion_EmptyDatabase(t *testing.T) {
|
||||
// Save original store and restore after test
|
||||
originalStore := store
|
||||
originalRootCtx := rootCtx
|
||||
defer func() {
|
||||
store = originalStore
|
||||
rootCtx = originalRootCtx
|
||||
}()
|
||||
|
||||
// Set up test context
|
||||
ctx := context.Background()
|
||||
rootCtx = ctx
|
||||
|
||||
// Create empty in-memory store
|
||||
memStore := memory.New("")
|
||||
store = memStore
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
args := []string{}
|
||||
|
||||
completions, directive := issueIDCompletion(cmd, args, "")
|
||||
|
||||
// Should return empty completions when database is empty
|
||||
if len(completions) != 0 {
|
||||
t.Errorf("Expected 0 completions when database is empty, got %d", len(completions))
|
||||
}
|
||||
|
||||
if directive != cobra.ShellCompDirectiveNoFileComp {
|
||||
t.Errorf("Expected directive NoFileComp (4), got %d", directive)
|
||||
}
|
||||
}
|
||||
@@ -162,5 +162,6 @@ Examples:
|
||||
func init() {
|
||||
// Time-based scheduling flag (GH#820)
|
||||
deferCmd.Flags().String("until", "", "Defer until specific time (e.g., +1h, tomorrow, next monday)")
|
||||
deferCmd.ValidArgsFunction = issueIDCompletion
|
||||
rootCmd.AddCommand(deferCmd)
|
||||
}
|
||||
|
||||
@@ -205,5 +205,6 @@ func init() {
|
||||
editCmd.Flags().Bool("design", false, "Edit the design notes")
|
||||
editCmd.Flags().Bool("notes", false, "Edit the notes")
|
||||
editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria")
|
||||
editCmd.ValidArgsFunction = issueIDCompletion
|
||||
rootCmd.AddCommand(editCmd)
|
||||
}
|
||||
|
||||
@@ -138,5 +138,6 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
|
||||
}
|
||||
func init() {
|
||||
reopenCmd.Flags().StringP("reason", "r", "", "Reason for reopening")
|
||||
reopenCmd.ValidArgsFunction = issueIDCompletion
|
||||
rootCmd.AddCommand(reopenCmd)
|
||||
}
|
||||
|
||||
@@ -821,5 +821,6 @@ func init() {
|
||||
showCmd.Flags().Bool("thread", false, "Show full conversation thread (for messages)")
|
||||
showCmd.Flags().Bool("short", false, "Show compact one-line output per issue")
|
||||
showCmd.Flags().Bool("refs", false, "Show issues that reference this issue (reverse lookup)")
|
||||
showCmd.ValidArgsFunction = issueIDCompletion
|
||||
rootCmd.AddCommand(showCmd)
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@ Examples:
|
||||
}
|
||||
|
||||
func init() {
|
||||
undeferCmd.ValidArgsFunction = issueIDCompletion
|
||||
rootCmd.AddCommand(undeferCmd)
|
||||
}
|
||||
|
||||
@@ -619,5 +619,6 @@ func init() {
|
||||
updateCmd.Flags().String("defer", "", "Defer until date (empty to clear). Issue hidden from bd ready until then")
|
||||
// Gate fields (bd-z6kw)
|
||||
updateCmd.Flags().String("await-id", "", "Set gate await_id (e.g., GitHub run ID for gh:run gates)")
|
||||
updateCmd.ValidArgsFunction = issueIDCompletion
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user