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:
Bob Cotton
2026-01-06 19:59:25 -07:00
committed by GitHub
parent bf378650f4
commit 025cdac962
9 changed files with 324 additions and 0 deletions

View File

@@ -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
View 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
View 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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -135,5 +135,6 @@ Examples:
}
func init() {
undeferCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(undeferCmd)
}

View File

@@ -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)
}