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("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().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.Flags().String("session", "", "Claude Code session ID (or set CLAUDE_SESSION_ID env var)")
closeCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(closeCmd) 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() { func init() {
// Time-based scheduling flag (GH#820) // Time-based scheduling flag (GH#820)
deferCmd.Flags().String("until", "", "Defer until specific time (e.g., +1h, tomorrow, next monday)") deferCmd.Flags().String("until", "", "Defer until specific time (e.g., +1h, tomorrow, next monday)")
deferCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(deferCmd) rootCmd.AddCommand(deferCmd)
} }

View File

@@ -205,5 +205,6 @@ func init() {
editCmd.Flags().Bool("design", false, "Edit the design notes") editCmd.Flags().Bool("design", false, "Edit the design notes")
editCmd.Flags().Bool("notes", false, "Edit the notes") editCmd.Flags().Bool("notes", false, "Edit the notes")
editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria") editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria")
editCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(editCmd) 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() { func init() {
reopenCmd.Flags().StringP("reason", "r", "", "Reason for reopening") reopenCmd.Flags().StringP("reason", "r", "", "Reason for reopening")
reopenCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(reopenCmd) 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("thread", false, "Show full conversation thread (for messages)")
showCmd.Flags().Bool("short", false, "Show compact one-line output per issue") 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.Flags().Bool("refs", false, "Show issues that reference this issue (reverse lookup)")
showCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(showCmd) rootCmd.AddCommand(showCmd)
} }

View File

@@ -135,5 +135,6 @@ Examples:
} }
func init() { func init() {
undeferCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(undeferCmd) 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") updateCmd.Flags().String("defer", "", "Defer until date (empty to clear). Issue hidden from bd ready until then")
// Gate fields (bd-z6kw) // Gate fields (bd-z6kw)
updateCmd.Flags().String("await-id", "", "Set gate await_id (e.g., GitHub run ID for gh:run gates)") updateCmd.Flags().String("await-id", "", "Set gate await_id (e.g., GitHub run ID for gh:run gates)")
updateCmd.ValidArgsFunction = issueIDCompletion
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateCmd)
} }