Add substring ID matching for all bd commands

- Enhanced ResolvePartialID to handle:
  - Bare hashes: 07b8c8 → bd-07b8c8
  - Prefix without hyphen: bd07b8c8 → bd-07b8c8
  - Full IDs: bd-07b8c8 (unchanged)
  - Substring matching: 07b → finds bd-07b8c8

- Added RPC support:
  - New OpResolveID operation
  - handleResolveID server handler
  - ResolveID client method

- Updated all commands to resolve IDs:
  - show, update, close, reopen
  - dep (add, remove, tree)
  - label (add, remove, list)

- Works in both daemon and direct modes

Fixes bd-0591c3
This commit is contained in:
Steve Yegge
2025-10-30 19:20:50 -07:00
parent 185c734d75
commit 11c26d5af8
10 changed files with 343 additions and 124 deletions

View File

@@ -1,6 +1,6 @@
{"id":"bd-0134cc5a","content_hash":"d45c0e44c01c5855f14f07693bd800f4bfeac3084e10ceb17970ff54c58f6a40","title":"Fix auto-import creating duplicates instead of updating issues","description":"ROOT CAUSE: server_export_import_auto.go line 221 uses ResolveCollisions: true for ALL auto-imports. This is wrong.\n\nProblem:\n- ResolveCollisions is for branch merges (different issues with same ID)\n- Auto-import should UPDATE existing issues, not create duplicates\n- Every git pull creates NEW duplicate issues with different IDs\n- Two agents ping-pong creating endless duplicates\n\nEvidence:\n- 31 duplicate groups found (bd duplicates)\n- bd-236-246 are duplicates of bd-224-235\n- Both agents keep pulling and creating more duplicates\n- JSONL file grows endlessly with duplicates\n\nThe Fix:\nChange checkAndAutoImportIfStale in server_export_import_auto.go:\n- Remove ResolveCollisions: true (line 221)\n- Use normal import logic that updates existing issues by ID\n- Only use ResolveCollisions for explicit bd import --resolve-collisions\n\nImpact: Critical - makes beads unusable for multi-agent workflows","acceptance_criteria":"- Auto-import does NOT create duplicates when pulling git changes\n- Existing issues are updated in-place by ID match\n- No ping-pong commits between agents\n- Test: two agents updating same issue should NOT create duplicates\n- bd duplicates shows 0 groups after fix","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-10-27T21:48:57.733846-07:00","updated_at":"2025-10-30T17:05:26.022586-07:00","closed_at":"2025-10-27T22:26:40.627239-07:00"}
{"id":"bd-0447029c","title":"bd find-duplicates - AI-powered duplicate detection","description":"Find semantically duplicate issues.\n\nApproaches:\n1. Mechanical: Exact title/description matching\n2. Embeddings: Cosine similarity (cheap, scalable)\n3. AI: LLM-based semantic comparison (expensive, accurate)\n\nUses embeddings by default for \u003e100 issues.\n\nFiles: cmd/bd/find_duplicates.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T16:43:28.182327-07:00","updated_at":"2025-10-30T17:05:25.998219-07:00","closed_at":"2025-10-29T16:15:10.64719-07:00"}
{"id":"bd-0591c3","content_hash":"558b48309ef0ab7e45e576075858b8693847512010ec74e07b2dadc397140bfa","title":"bd show should accept partial IDs and auto-add prefix","description":"Agent tried `bd show 667565` (hash only, no prefix) and got \"Issue wy667565 not found\". Then tried `bd show wy-667565` which worked.\n\nCommands should:\n1. Accept bare hash (e.g., \"667565\" → \"wy-667565\")\n2. Accept prefix without hyphen (e.g., \"wy667565\" → \"wy-667565\")\n3. Support substring matching for partial hashes (e.g., \"6675\" → find matches)\n\nThis affects show, update, close, dep, label commands.","status":"open","priority":1,"issue_type":"bug","created_at":"2025-10-30T19:05:17.694824-07:00","updated_at":"2025-10-30T19:05:17.694824-07:00"}
{"id":"bd-0591c3","content_hash":"b0bd9523f023c8f152b15abfcea955e70dac97b65726cf4780f0a32198acb96e","title":"bd show should accept partial IDs and auto-add prefix","description":"Agent tried `bd show 667565` (hash only, no prefix) and got \"Issue wy667565 not found\". Then tried `bd show wy-667565` which worked.\n\nCommands should:\n1. Accept bare hash (e.g., \"667565\" → \"wy-667565\")\n2. Accept prefix without hyphen (e.g., \"wy667565\" → \"wy-667565\")\n3. Support substring matching for partial hashes (e.g., \"6675\" → find matches)\n\nThis affects show, update, close, dep, label commands.","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-30T19:05:17.694824-07:00","updated_at":"2025-10-30T19:14:41.792597-07:00","closed_at":"2025-10-30T19:14:41.792601-07:00"}
{"id":"bd-0650a73b","content_hash":"a596aa8d6114d4938471e181ebc30da5d0315f74fd711a92dbbb83f5d0e7af88","title":"Create cmd/bd/daemon_debouncer.go (~60 LOC)","description":"Implement Debouncer to batch rapid events into single action. Default 500ms, configurable via BEADS_DEBOUNCE_MS. Thread-safe with mutex.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T16:20:02.431118-07:00","updated_at":"2025-10-30T17:05:26.039318-07:00","closed_at":"2025-10-28T12:03:35.614191-07:00"}
{"id":"bd-06aec0c3","content_hash":"330e69cf6ca40209948559b453ed5242c15a71b5c949a858ad6854488b12dca2","title":"Integration Testing","description":"Verify cache removal doesn't break any workflows","acceptance_criteria":"- All test cases pass\n- No stale data observed\n- Performance is same or better\n- MCP works as before\n\nTest cases:\n1. Basic daemon operations (bd daemon --stop, bd daemon, bd list, bd create, bd show)\n2. Auto-import/export cycle (edit beads.jsonl externally, bd list auto-imports)\n3. Git workflow (git pull updates beads.jsonl, bd list shows pulled issues)\n4. Concurrent operations (multiple bd commands simultaneously)\n5. Daemon health (bd daemon --health, bd daemon --metrics)\n6. MCP operations (test MCP server with multiple repos, verify project switching)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-28T10:50:15.126668-07:00","updated_at":"2025-10-30T17:05:26.030232-07:00","closed_at":"2025-10-28T10:49:20.471129-07:00"}
{"id":"bd-07b8c8","content_hash":"6d2f495c5f33b476936e25159e007c4ca46f089e2e24944899b78103dae6c392","title":"Replace filesystem discovery with daemon registry","description":"Current design recursively scans home directory for bd.sock files, causing indefinite hangs. Need daemons to register themselves in a central location (e.g., ~/.beads/registry.json or socket in known location) that bd daemons list can query instantly without filesystem traversal.","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-10-30T18:29:41.173371-07:00","updated_at":"2025-10-30T18:37:01.317949-07:00","closed_at":"2025-10-30T18:37:01.317949-07:00","dependencies":[{"issue_id":"bd-07b8c8","depends_on_id":"bd-acb971c7","type":"discovered-from","created_at":"2025-10-30T18:29:41.174947-07:00","created_by":"stevey"}]}

View File

@@ -26,11 +26,46 @@ var depAddCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
depType, _ := cmd.Flags().GetString("type")
ctx := context.Background()
// Resolve partial IDs first
var fromID, toID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
os.Exit(1)
}
fromID = string(resp.Data)
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
resp, err = daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err)
os.Exit(1)
}
toID = string(resp.Data)
} else {
var err error
fromID, 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)
}
toID, 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 daemon is running, use RPC
if daemonClient != nil {
depArgs := &rpc.DepAddArgs{
FromID: args[0],
ToID: args[1],
FromID: fromID,
ToID: toID,
DepType: depType,
}
@@ -52,23 +87,9 @@ 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: fullFromID,
DependsOnID: fullToID,
IssueID: fromID,
DependsOnID: toID,
Type: types.DependencyType(depType),
}
@@ -108,8 +129,8 @@ var depAddCmd = &cobra.Command{
if jsonOutput {
outputJSON(map[string]interface{}{
"status": "added",
"issue_id": fullFromID,
"depends_on_id": fullToID,
"issue_id": fromID,
"depends_on_id": toID,
"type": depType,
})
return
@@ -117,7 +138,7 @@ var depAddCmd = &cobra.Command{
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Added dependency: %s depends on %s (%s)\n",
green("✓"), fullFromID, fullToID, depType)
green("✓"), fromID, toID, depType)
},
}
@@ -126,11 +147,46 @@ var depRemoveCmd = &cobra.Command{
Short: "Remove a dependency",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
// Resolve partial IDs first
var fromID, toID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
os.Exit(1)
}
fromID = string(resp.Data)
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
resp, err = daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err)
os.Exit(1)
}
toID = string(resp.Data)
} else {
var err error
fromID, 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)
}
toID, 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 daemon is running, use RPC
if daemonClient != nil {
depArgs := &rpc.DepRemoveArgs{
FromID: args[0],
ToID: args[1],
FromID: fromID,
ToID: toID,
}
resp, err := daemonClient.RemoveDependency(depArgs)
@@ -146,24 +202,13 @@ var depRemoveCmd = &cobra.Command{
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Removed dependency: %s no longer depends on %s\n",
green("✓"), args[0], args[1])
green("✓"), fromID, toID)
return
}
// 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)
}
fullFromID := fromID
fullToID := toID
if err := store.RemoveDependency(ctx, fullFromID, fullToID, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
@@ -193,6 +238,27 @@ var depTreeCmd = &cobra.Command{
Short: "Show dependency tree",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
// Resolve partial ID first
var fullID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
os.Exit(1)
}
fullID = string(resp.Data)
} else {
var err error
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)
}
}
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
var err error
@@ -212,14 +278,6 @@ var depTreeCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n")
os.Exit(1)
}
ctx := context.Background()
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 {

View File

@@ -81,20 +81,31 @@ var labelAddCmd = &cobra.Command{
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)
// Resolve partial IDs
ctx := context.Background()
resolvedIDs := make([]string, 0, len(issueIDs))
for _, id := range issueIDs {
var fullID string
var err error
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
fullID = string(resp.Data)
} else {
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
resolvedIDs = append(resolvedIDs, fullID)
}
issueIDs = resolvedIDs
processBatchLabelOperation(issueIDs, label, "added",
func(issueID, lbl string) error {
@@ -115,20 +126,31 @@ var labelRemoveCmd = &cobra.Command{
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)
// Resolve partial IDs
ctx := context.Background()
resolvedIDs := make([]string, 0, len(issueIDs))
for _, id := range issueIDs {
var fullID string
var err error
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
fullID = string(resp.Data)
} else {
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
resolvedIDs = append(resolvedIDs, fullID)
}
issueIDs = resolvedIDs
processBatchLabelOperation(issueIDs, label, "removed",
func(issueID, lbl string) error {
@@ -146,21 +168,29 @@ var labelListCmd = &cobra.Command{
Short: "List labels for an issue",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
issueID := args[0]
ctx := context.Background()
var labels []string
// Resolve partial ID if in direct mode
if daemonClient == nil {
fullID, err := utils.ResolvePartialID(ctx, store, issueID)
// Resolve partial ID first
var issueID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", issueID, err)
fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err)
os.Exit(1)
}
issueID = string(resp.Data)
} else {
var err error
issueID, 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)
}
issueID = fullID
}
var labels []string
// Use daemon if available
if daemonClient != nil {
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID})

View File

@@ -24,11 +24,33 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
reason, _ := cmd.Flags().GetString("reason")
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, string(resp.Data))
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
reopenedIssues := []*types.Issue{}
// If daemon is running, use RPC
if daemonClient != nil {
for _, id := range args {
for _, id := range resolvedIDs {
openStatus := string(types.StatusOpen)
updateArgs := &rpc.UpdateArgs{
ID: id,

View File

@@ -20,10 +20,35 @@ var showCmd = &cobra.Command{
Short: "Show issue details",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
// In daemon mode, resolve via RPC
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, string(resp.Data))
}
} else {
// In direct mode, resolve via storage
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
allDetails := []interface{}{}
for idx, id := range args {
for idx, id := range resolvedIDs {
showArgs := &rpc.ShowArgs{ID: id}
resp, err := daemonClient.Show(showArgs)
if err != nil {
@@ -158,22 +183,15 @@ var showCmd = &cobra.Command{
}
// Direct mode
ctx := context.Background()
allDetails := []interface{}{}
for idx, id := range args {
fullID, err := utils.ResolvePartialID(ctx, store, id)
for idx, id := range resolvedIDs {
issue, err := store.GetIssue(ctx, id)
if err != nil {
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)
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if issue == nil {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", fullID)
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
@@ -360,10 +378,33 @@ var updateCmd = &cobra.Command{
return
}
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, string(resp.Data))
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
updatedIssues := []*types.Issue{}
for _, id := range args {
for _, id := range resolvedIDs {
updateArgs := &rpc.UpdateArgs{ID: id}
// Map updates to RPC args
@@ -416,28 +457,21 @@ var updateCmd = &cobra.Command{
}
// Direct mode
ctx := context.Background()
updatedIssues := []*types.Issue{}
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
}
if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", fullID, err)
continue
}
for _, id := range resolvedIDs {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, fullID)
if jsonOutput {
issue, _ := store.GetIssue(ctx, id)
if issue != nil {
updatedIssues = append(updatedIssues, issue)
}
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), fullID)
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
}
}
@@ -658,10 +692,33 @@ var closeCmd = &cobra.Command{
reason = "Closed"
}
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, string(resp.Data))
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
closedIssues := []*types.Issue{}
for _, id := range args {
for _, id := range resolvedIDs {
closeArgs := &rpc.CloseArgs{
ID: id,
Reason: reason,
@@ -690,27 +747,20 @@ var closeCmd = &cobra.Command{
}
// Direct mode
ctx := context.Background()
closedIssues := []*types.Issue{}
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
}
if err := store.CloseIssue(ctx, fullID, reason, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", fullID, err)
for _, id := range resolvedIDs {
if err := store.CloseIssue(ctx, id, reason, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, fullID)
issue, _ := store.GetIssue(ctx, id)
if issue != nil {
closedIssues = append(closedIssues, issue)
}
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Closed %s: %s\n", green("✓"), fullID, reason)
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
}
}

View File

@@ -255,6 +255,11 @@ func (c *Client) Show(args *ShowArgs) (*Response, error) {
return c.Execute(OpShow, args)
}
// ResolveID resolves a partial issue ID to a full ID via the daemon
func (c *Client) ResolveID(args *ResolveIDArgs) (*Response, error) {
return c.Execute(OpResolveID, args)
}
// Ready gets ready work via the daemon
func (c *Client) Ready(args *ReadyArgs) (*Response, error) {
return c.Execute(OpReady, args)

View File

@@ -25,6 +25,7 @@ const (
OpCommentList = "comment_list"
OpCommentAdd = "comment_add"
OpBatch = "batch"
OpResolveID = "resolve_id"
OpCompact = "compact"
OpCompactStats = "compact_stats"
@@ -104,6 +105,11 @@ type ShowArgs struct {
ID string `json:"id"`
}
// ResolveIDArgs represents arguments for the resolve_id operation
type ResolveIDArgs struct {
ID string `json:"id"`
}
// ReadyArgs represents arguments for the ready operation
type ReadyArgs struct {
Assignee string `json:"assignee,omitempty"`

View File

@@ -6,6 +6,7 @@ import (
"strings"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// normalizeLabels trims whitespace, removes empty strings, and deduplicates labels
@@ -319,6 +320,31 @@ func (s *Server) handleList(req *Request) Response {
}
}
func (s *Server) handleResolveID(req *Request) Response {
var args ResolveIDArgs
if err := json.Unmarshal(req.Args, &args); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid resolve_id args: %v", err),
}
}
ctx := s.reqCtx(req)
resolvedID, err := utils.ResolvePartialID(ctx, s.storage, args.ID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to resolve ID: %v", err),
}
}
data, _ := json.Marshal(resolvedID)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleShow(req *Request) Response {
var showArgs ShowArgs
if err := json.Unmarshal(req.Args, &showArgs); err != nil {

View File

@@ -168,6 +168,8 @@ func (s *Server) handleRequest(req *Request) Response {
resp = s.handleList(req)
case OpShow:
resp = s.handleShow(req)
case OpResolveID:
resp = s.handleResolveID(req)
case OpReady:
resp = s.handleReady(req)
case OpStats:

View File

@@ -29,32 +29,47 @@ func ParseIssueID(input string, prefix string) string {
// 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)
// - 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)
//
// 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-"
prefix = "bd"
}
// Ensure the input has the prefix
parsedID := ParseIssueID(input, prefix)
// 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
_, err = store.GetIssue(ctx, parsedID)
if err == nil {
return parsedID, nil
issue, err := store.GetIssue(ctx, normalizedID)
if err == nil && issue != nil {
return normalizedID, nil
}
// If exact match failed, try prefix search
// If exact match failed, try substring search
filter := types.IssueFilter{}
issues, err := store.SearchIssues(ctx, "", filter)
@@ -62,9 +77,14 @@ func ResolvePartialID(ctx context.Context, store storage.Storage, input string)
return "", fmt.Errorf("failed to search issues: %w", err)
}
// Extract the hash part for substring matching
hashPart := strings.TrimPrefix(normalizedID, prefix)
var matches []string
for _, issue := range issues {
if strings.HasPrefix(issue.ID, parsedID) {
issueHash := strings.TrimPrefix(issue.ID, prefix)
// Check if the issue hash contains the input hash as substring
if strings.Contains(issueHash, hashPart) {
matches = append(matches, issue.ID)
}
}