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("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
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() {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,5 +135,6 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
undeferCmd.ValidArgsFunction = issueIDCompletion
|
||||||
rootCmd.AddCommand(undeferCmd)
|
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")
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user