// 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" // - Without hyphen: "bda3f8e9" or "wya3f8e9" → "bd-a3f8e9" // - Partial IDs: "a3f8" → "bd-a3f8e9" (if unique match) // - Hierarchical: "a3f8e9.1" → "bd-a3f8e9.1" // // Returns an error if: // - No issue found matching the ID // - Multiple issues match (ambiguous prefix) func ResolvePartialID(ctx context.Context, store storage.Storage, input string) (string, error) { // Fast path: if the user typed an exact ID that exists, return it as-is. // This preserves behavior where issue IDs may not match the configured // issue_prefix (e.g. cross-repo IDs like "ao-izl"), while still allowing // prefix-based and hash-based resolution for other inputs. if issue, err := store.GetIssue(ctx, input); err == nil && issue != nil { return input, nil } // Get the configured prefix prefix, err := store.GetConfig(ctx, "issue_prefix") if err != nil || prefix == "" { prefix = "bd" } // Ensure prefix has hyphen for ID format prefixWithHyphen := prefix if !strings.HasSuffix(prefix, "-") { prefixWithHyphen = prefix + "-" } // Normalize input: // 1. If it has the full prefix with hyphen (bd-a3f8e9), use as-is // 2. Otherwise, add prefix with hyphen (handles both bare hashes and prefix-without-hyphen cases) var normalizedID string if strings.HasPrefix(input, prefixWithHyphen) { // Already has prefix with hyphen: "bd-a3f8e9" normalizedID = input } else { // Bare hash or prefix without hyphen: "a3f8e9", "07b8c8", "bda3f8e9" → all get prefix with hyphen added normalizedID = prefixWithHyphen + input } // First try exact match on normalized ID issue, err := store.GetIssue(ctx, normalizedID) if err == nil && issue != nil { return normalizedID, nil } // If exact match failed, try substring search filter := types.IssueFilter{} issues, err := store.SearchIssues(ctx, "", filter) if err != nil { return "", fmt.Errorf("failed to search issues: %w", err) } // Extract the hash part for substring matching hashPart := strings.TrimPrefix(normalizedID, prefixWithHyphen) var matches []string var exactMatch string for _, issue := range issues { // Check for exact full ID match first (case: user typed full ID with different prefix) if issue.ID == input { exactMatch = issue.ID break } // Extract hash from each issue, regardless of its prefix // This handles cross-prefix matching (e.g., "3d0" matching "offlinebrew-3d0") var issueHash string if idx := strings.Index(issue.ID, "-"); idx >= 0 { issueHash = issue.ID[idx+1:] } else { issueHash = issue.ID } // Check for exact hash match (excluding hierarchical children) if issueHash == hashPart { exactMatch = issue.ID // Don't break - keep searching in case there's a full ID match } // Check if the issue hash contains the input hash as substring if strings.Contains(issueHash, hashPart) { matches = append(matches, issue.ID) } } // Prefer exact match over substring matches if exactMatch != "" { return exactMatch, nil } 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 }