diff --git a/cmd/bd/comments.go b/cmd/bd/comments.go index f2b4dfe5..94c12ace 100644 --- a/cmd/bd/comments.go +++ b/cmd/bd/comments.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) var commentsCmd = &cobra.Command{ @@ -63,6 +64,13 @@ Examples: os.Exit(1) } ctx := context.Background() + fullID, err := utils.ResolvePartialID(ctx, store, issueID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", issueID, err) + os.Exit(1) + } + issueID = fullID + result, err := store.GetIssueComments(ctx, issueID) if err != nil { fmt.Fprintf(os.Stderr, "Error getting comments: %v\n", err) @@ -176,7 +184,14 @@ Examples: os.Exit(1) } ctx := context.Background() - var err error + + fullID, err := utils.ResolvePartialID(ctx, store, issueID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", issueID, err) + os.Exit(1) + } + issueID = fullID + comment, err = store.AddIssueComment(ctx, issueID, author, commentText) if err != nil { fmt.Fprintf(os.Stderr, "Error adding comment: %v\n", err) diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 9e9f803e..cf757879 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -11,6 +11,7 @@ import ( "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) var depCmd = &cobra.Command{ @@ -51,13 +52,26 @@ var depAddCmd = &cobra.Command{ } // Direct mode + ctx := context.Background() + + fullFromID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) + os.Exit(1) + } + + fullToID, err := utils.ResolvePartialID(ctx, store, args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) + os.Exit(1) + } + dep := &types.Dependency{ - IssueID: args[0], - DependsOnID: args[1], + IssueID: fullFromID, + DependsOnID: fullToID, Type: types.DependencyType(depType), } - ctx := context.Background() if err := store.AddDependency(ctx, dep, actor); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -94,8 +108,8 @@ var depAddCmd = &cobra.Command{ if jsonOutput { outputJSON(map[string]interface{}{ "status": "added", - "issue_id": args[0], - "depends_on_id": args[1], + "issue_id": fullFromID, + "depends_on_id": fullToID, "type": depType, }) return @@ -103,7 +117,7 @@ var depAddCmd = &cobra.Command{ green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", - green("✓"), args[0], args[1], depType) + green("✓"), fullFromID, fullToID, depType) }, } @@ -138,7 +152,20 @@ var depRemoveCmd = &cobra.Command{ // Direct mode ctx := context.Background() - if err := store.RemoveDependency(ctx, args[0], args[1], actor); err != nil { + + fullFromID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) + os.Exit(1) + } + + fullToID, err := utils.ResolvePartialID(ctx, store, args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) + os.Exit(1) + } + + if err := store.RemoveDependency(ctx, fullFromID, fullToID, actor); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -149,15 +176,15 @@ var depRemoveCmd = &cobra.Command{ if jsonOutput { outputJSON(map[string]interface{}{ "status": "removed", - "issue_id": args[0], - "depends_on_id": args[1], + "issue_id": fullFromID, + "depends_on_id": fullToID, }) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", - green("✓"), args[0], args[1]) + green("✓"), fullFromID, fullToID) }, } @@ -187,7 +214,14 @@ var depTreeCmd = &cobra.Command{ } ctx := context.Background() - tree, err := store.GetDependencyTree(ctx, args[0], maxDepth, showAllPaths, reverse) + + fullID, err := utils.ResolvePartialID(ctx, store, args[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", args[0], err) + os.Exit(1) + } + + tree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, reverse) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -204,18 +238,18 @@ var depTreeCmd = &cobra.Command{ if len(tree) == 0 { if reverse { - fmt.Printf("\n%s has no dependents\n", args[0]) + fmt.Printf("\n%s has no dependents\n", fullID) } else { - fmt.Printf("\n%s has no dependencies\n", args[0]) + fmt.Printf("\n%s has no dependencies\n", fullID) } return } cyan := color.New(color.FgCyan).SprintFunc() if reverse { - fmt.Printf("\n%s Dependent tree for %s:\n\n", cyan("🌲"), args[0]) + fmt.Printf("\n%s Dependent tree for %s:\n\n", cyan("🌲"), fullID) } else { - fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), args[0]) + fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), fullID) } hasTruncation := false diff --git a/cmd/bd/label.go b/cmd/bd/label.go index bbb273a5..68714be8 100644 --- a/cmd/bd/label.go +++ b/cmd/bd/label.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) var labelCmd = &cobra.Command{ @@ -79,6 +80,22 @@ var labelAddCmd = &cobra.Command{ Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { issueIDs, label := parseLabelArgs(args) + + // Resolve partial IDs if in direct mode + if daemonClient == nil { + ctx := context.Background() + resolvedIDs := make([]string, 0, len(issueIDs)) + for _, id := range issueIDs { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + resolvedIDs = append(resolvedIDs, fullID) + } + issueIDs = resolvedIDs + } + processBatchLabelOperation(issueIDs, label, "added", func(issueID, lbl string) error { _, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: issueID, Label: lbl}) @@ -97,6 +114,22 @@ var labelRemoveCmd = &cobra.Command{ Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { issueIDs, label := parseLabelArgs(args) + + // Resolve partial IDs if in direct mode + if daemonClient == nil { + ctx := context.Background() + resolvedIDs := make([]string, 0, len(issueIDs)) + for _, id := range issueIDs { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + resolvedIDs = append(resolvedIDs, fullID) + } + issueIDs = resolvedIDs + } + processBatchLabelOperation(issueIDs, label, "removed", func(issueID, lbl string) error { _, err := daemonClient.RemoveLabel(&rpc.LabelRemoveArgs{ID: issueID, Label: lbl}) @@ -117,6 +150,16 @@ var labelListCmd = &cobra.Command{ ctx := context.Background() var labels []string + + // Resolve partial ID if in direct mode + if daemonClient == nil { + fullID, err := utils.ResolvePartialID(ctx, store, issueID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", issueID, err) + os.Exit(1) + } + issueID = fullID + } // Use daemon if available if daemonClient != nil { diff --git a/cmd/bd/reopen.go b/cmd/bd/reopen.go index d570ef37..f22890a8 100644 --- a/cmd/bd/reopen.go +++ b/cmd/bd/reopen.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) var reopenCmd = &cobra.Command{ @@ -73,24 +74,30 @@ This is more explicit than 'bd update --status open' and emits a Reopened event. } for _, id := range args { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + // UpdateIssue automatically clears closed_at when status changes from closed updates := map[string]interface{}{ "status": string(types.StatusOpen), } - if err := store.UpdateIssue(ctx, id, updates, actor); err != nil { - fmt.Fprintf(os.Stderr, "Error reopening %s: %v\n", id, err) + if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error reopening %s: %v\n", fullID, err) continue } // Add reason as a comment if provided if reason != "" { - if err := store.AddComment(ctx, id, actor, reason); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to add comment to %s: %v\n", id, err) + if err := store.AddComment(ctx, fullID, actor, reason); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to add comment to %s: %v\n", fullID, err) } } if jsonOutput { - issue, _ := store.GetIssue(ctx, id) + issue, _ := store.GetIssue(ctx, fullID) if issue != nil { reopenedIssues = append(reopenedIssues, issue) } @@ -100,7 +107,7 @@ This is more explicit than 'bd update --status open' and emits a Reopened event. if reason != "" { reasonMsg = ": " + reason } - fmt.Printf("%s Reopened %s%s\n", blue("↻"), id, reasonMsg) + fmt.Printf("%s Reopened %s%s\n", blue("↻"), fullID, reasonMsg) } } diff --git a/cmd/bd/show.go b/cmd/bd/show.go index fb894a9a..596f67b5 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/utils" ) var showCmd = &cobra.Command{ @@ -160,13 +161,19 @@ var showCmd = &cobra.Command{ ctx := context.Background() allDetails := []interface{}{} for idx, id := range args { - issue, err := store.GetIssue(ctx, id) + fullID, err := utils.ResolvePartialID(ctx, store, id) if err != nil { - fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err) + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + + issue, err := store.GetIssue(ctx, fullID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", fullID, err) continue } if issue == nil { - fmt.Fprintf(os.Stderr, "Issue %s not found\n", id) + fmt.Fprintf(os.Stderr, "Issue %s not found\n", fullID) continue } @@ -412,19 +419,25 @@ var updateCmd = &cobra.Command{ ctx := context.Background() updatedIssues := []*types.Issue{} for _, id := range args { - if err := store.UpdateIssue(ctx, id, updates, actor); err != nil { - fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err) + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + + if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", fullID, err) continue } if jsonOutput { - issue, _ := store.GetIssue(ctx, id) + issue, _ := store.GetIssue(ctx, fullID) if issue != nil { updatedIssues = append(updatedIssues, issue) } } else { green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Updated issue: %s\n", green("✓"), id) + fmt.Printf("%s Updated issue: %s\n", green("✓"), fullID) } } @@ -456,6 +469,16 @@ Examples: Run: func(cmd *cobra.Command, args []string) { id := args[0] ctx := context.Background() + + // Resolve partial ID if in direct mode + if daemonClient == nil { + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + os.Exit(1) + } + id = fullID + } // Determine which field to edit fieldToEdit := "description" @@ -670,18 +693,24 @@ var closeCmd = &cobra.Command{ ctx := context.Background() closedIssues := []*types.Issue{} for _, id := range args { - if err := store.CloseIssue(ctx, id, reason, actor); err != nil { - fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) + fullID, err := utils.ResolvePartialID(ctx, store, id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) + continue + } + + if err := store.CloseIssue(ctx, fullID, reason, actor); err != nil { + fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", fullID, err) continue } if jsonOutput { - issue, _ := store.GetIssue(ctx, id) + issue, _ := store.GetIssue(ctx, fullID) if issue != nil { closedIssues = append(closedIssues, issue) } } else { green := color.New(color.FgGreen).SprintFunc() - fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) + fmt.Printf("%s Closed %s: %s\n", green("✓"), fullID, reason) } } diff --git a/internal/utils/id_parser.go b/internal/utils/id_parser.go new file mode 100644 index 00000000..eefd8169 --- /dev/null +++ b/internal/utils/id_parser.go @@ -0,0 +1,95 @@ +// Package utils provides utility functions for issue ID parsing and resolution. +package utils + +import ( + "context" + "fmt" + "strings" + + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/types" +) + +// ParseIssueID ensures an issue ID has the configured prefix. +// If the input already has the prefix (e.g., "bd-a3f8e9"), returns it as-is. +// If the input lacks the prefix (e.g., "a3f8e9"), adds the configured prefix. +// Works with hierarchical IDs too: "a3f8e9.1.2" → "bd-a3f8e9.1.2" +func ParseIssueID(input string, prefix string) string { + if prefix == "" { + prefix = "bd-" + } + + if strings.HasPrefix(input, prefix) { + return input + } + + return prefix + input +} + +// ResolvePartialID resolves a potentially partial issue ID to a full ID. +// Supports: +// - Full IDs: "bd-a3f8e9" or "a3f8e9" → "bd-a3f8e9" +// - Partial IDs: "a3f8" → "bd-a3f8e9" (if unique match, requires hash IDs) +// - Hierarchical: "a3f8e9.1" → "bd-a3f8e9.1" +// +// Returns an error if: +// - No issue found matching the ID +// - Multiple issues match (ambiguous prefix) +// +// Note: Partial ID matching (shorter prefixes) requires hash-based IDs (bd-165). +// For now, this primarily handles prefix-optional input (bd-a3f8e9 vs a3f8e9). +func ResolvePartialID(ctx context.Context, store storage.Storage, input string) (string, error) { + // Get the configured prefix + prefix, err := store.GetConfig(ctx, "issue_prefix") + if err != nil || prefix == "" { + prefix = "bd-" + } + + // Ensure the input has the prefix + parsedID := ParseIssueID(input, prefix) + + // First try exact match + _, err = store.GetIssue(ctx, parsedID) + if err == nil { + return parsedID, nil + } + + // If exact match failed, try prefix search + filter := types.IssueFilter{} + + issues, err := store.SearchIssues(ctx, "", filter) + if err != nil { + return "", fmt.Errorf("failed to search issues: %w", err) + } + + var matches []string + for _, issue := range issues { + if strings.HasPrefix(issue.ID, parsedID) { + matches = append(matches, issue.ID) + } + } + + if len(matches) == 0 { + return "", fmt.Errorf("no issue found matching %q", input) + } + + if len(matches) > 1 { + return "", fmt.Errorf("ambiguous ID %q matches %d issues: %v\nUse more characters to disambiguate", input, len(matches), matches) + } + + return matches[0], nil +} + +// ResolvePartialIDs resolves multiple potentially partial issue IDs. +// Returns the resolved IDs and any errors encountered. +func ResolvePartialIDs(ctx context.Context, store storage.Storage, inputs []string) ([]string, error) { + var resolved []string + for _, input := range inputs { + fullID, err := ResolvePartialID(ctx, store, input) + if err != nil { + return nil, err + } + resolved = append(resolved, fullID) + } + return resolved, nil +} diff --git a/internal/utils/id_parser_test.go b/internal/utils/id_parser_test.go new file mode 100644 index 00000000..419e0312 --- /dev/null +++ b/internal/utils/id_parser_test.go @@ -0,0 +1,247 @@ +package utils + +import ( + "context" + "testing" + + "github.com/steveyegge/beads/internal/storage/memory" + "github.com/steveyegge/beads/internal/types" +) + +func TestParseIssueID(t *testing.T) { + tests := []struct { + name string + input string + prefix string + expected string + }{ + { + name: "already has prefix", + input: "bd-a3f8e9", + prefix: "bd-", + expected: "bd-a3f8e9", + }, + { + name: "missing prefix", + input: "a3f8e9", + prefix: "bd-", + expected: "bd-a3f8e9", + }, + { + name: "hierarchical with prefix", + input: "bd-a3f8e9.1.2", + prefix: "bd-", + expected: "bd-a3f8e9.1.2", + }, + { + name: "hierarchical without prefix", + input: "a3f8e9.1.2", + prefix: "bd-", + expected: "bd-a3f8e9.1.2", + }, + { + name: "custom prefix with ID", + input: "ticket-123", + prefix: "ticket-", + expected: "ticket-123", + }, + { + name: "custom prefix without ID", + input: "123", + prefix: "ticket-", + expected: "ticket-123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseIssueID(tt.input, tt.prefix) + if result != tt.expected { + t.Errorf("ParseIssueID(%q, %q) = %q; want %q", tt.input, tt.prefix, result, tt.expected) + } + }) + } +} + +func TestResolvePartialID(t *testing.T) { + ctx := context.Background() + store := memory.New("") + + // Create test issues with sequential IDs (current implementation) + // When hash IDs (bd-165) are implemented, these can be hash-based + issue1 := &types.Issue{ + ID: "bd-1", + Title: "Test Issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + issue2 := &types.Issue{ + ID: "bd-2", + Title: "Test Issue 2", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + issue3 := &types.Issue{ + ID: "bd-10", + Title: "Test Issue 3", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatal(err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatal(err) + } + if err := store.CreateIssue(ctx, issue3, "test"); err != nil { + t.Fatal(err) + } + + // Set config for prefix + if err := store.SetConfig(ctx, "issue_prefix", "bd-"); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + input string + expected string + shouldError bool + errorMsg string + }{ + { + name: "exact match with prefix", + input: "bd-1", + expected: "bd-1", + }, + { + name: "exact match without prefix", + input: "1", + expected: "bd-1", + }, + { + name: "exact match with prefix (two digits)", + input: "bd-10", + expected: "bd-10", + }, + { + name: "exact match without prefix (two digits)", + input: "10", + expected: "bd-10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ResolvePartialID(ctx, store, tt.input) + + if tt.shouldError { + if err == nil { + t.Errorf("ResolvePartialID(%q) expected error containing %q, got nil", tt.input, tt.errorMsg) + } else if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { + t.Errorf("ResolvePartialID(%q) error = %q; want error containing %q", tt.input, err.Error(), tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("ResolvePartialID(%q) unexpected error: %v", tt.input, err) + } + if result != tt.expected { + t.Errorf("ResolvePartialID(%q) = %q; want %q", tt.input, result, tt.expected) + } + } + }) + } +} + +func TestResolvePartialIDs(t *testing.T) { + ctx := context.Background() + store := memory.New("") + + // Create test issues + issue1 := &types.Issue{ + ID: "bd-1", + Title: "Test Issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + issue2 := &types.Issue{ + ID: "bd-2", + Title: "Test Issue 2", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + + if err := store.CreateIssue(ctx, issue1, "test"); err != nil { + t.Fatal(err) + } + if err := store.CreateIssue(ctx, issue2, "test"); err != nil { + t.Fatal(err) + } + + if err := store.SetConfig(ctx, "issue_prefix", "bd-"); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + inputs []string + expected []string + shouldError bool + }{ + { + name: "resolve multiple IDs without prefix", + inputs: []string{"1", "2"}, + expected: []string{"bd-1", "bd-2"}, + }, + { + name: "resolve mixed full and partial IDs", + inputs: []string{"bd-1", "2"}, + expected: []string{"bd-1", "bd-2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ResolvePartialIDs(ctx, store, tt.inputs) + + if tt.shouldError { + if err == nil { + t.Errorf("ResolvePartialIDs(%v) expected error, got nil", tt.inputs) + } + } else { + if err != nil { + t.Errorf("ResolvePartialIDs(%v) unexpected error: %v", tt.inputs, err) + } + if len(result) != len(tt.expected) { + t.Errorf("ResolvePartialIDs(%v) returned %d results; want %d", tt.inputs, len(result), len(tt.expected)) + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("ResolvePartialIDs(%v)[%d] = %q; want %q", tt.inputs, i, result[i], tt.expected[i]) + } + } + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && + (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || + findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}