- Add fast path for exact ID matches in ResolvePartialID
- Properly unmarshal ResolveID RPC responses used by `bd show`
Summary:
--------
Fixes regression introduced after v0.23.1 where `bd show <issue_id>`,
`bd update <issue_id>`, and `bd close <issue_id>` commands failed with
"operation failed: issue not found" errors even when the issue existed
in the database.
Root Cause:
-----------
The daemon's ResolveID RPC handler (handleResolveID in
internal/rpc/server_issues_epics.go) returns a JSON-encoded string
containing the resolved issue ID. For example, when resolving "ao-izl",
the RPC response contains the JSON string: "ao-izl" (with quotes).
After v0.23.1, the show/update/close commands in cmd/bd/show.go were
changed to use string(resp.Data) to extract the resolved ID, which
treats the raw bytes as a string without decoding the JSON. This caused
the resolved ID to literally be "ao-izl" (including the quotes),
which then failed to match the actual issue ID ao-izl (without quotes)
when passed to subsequent Show/Update/Close RPC calls.
In v0.23.1 (commit 77dcf55), the code correctly unmarshaled the JSON:
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
// handle error
}
This unmarshal step was removed in later commits, causing the regression.
Fix:
----
Restored the JSON unmarshaling step in three places in cmd/bd/show.go:
1. showCmd - line 37-42
2. updateCmd - line 400-405
3. closeCmd - line 723-728
Each now properly decodes the JSON string response before using the
resolved ID in subsequent RPC calls.
Testing:
--------
- Verified `bd show <issue-id-with-prefix>` works correctly in both daemon and direct modes
- Tested with newly created issues to ensure the fix works for all IDs
- All Go tests pass (go test ./... -short)
- Confirmed behavior matches v0.23.1 working binary
The fix ensures that issue ID resolution works correctly regardless of
whether the ID prefix matches the configured issue_prefix.
151 lines
4.5 KiB
Go
151 lines
4.5 KiB
Go
// 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
|
|
}
|