From 11c26d5af8a5024cd1781bbf15b70e79fb468c33 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 30 Oct 2025 19:20:50 -0700 Subject: [PATCH] Add substring ID matching for all bd commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .beads/beads.jsonl | 2 +- cmd/bd/dep.go | 148 ++++++++++++------ cmd/bd/label.go | 78 ++++++--- cmd/bd/reopen.go | 24 ++- cmd/bd/show.go | 132 +++++++++++----- internal/rpc/client.go | 5 + internal/rpc/protocol.go | 6 + internal/rpc/server_issues_epics.go | 26 +++ .../server_routing_validation_diagnostics.go | 2 + internal/utils/id_parser.go | 44 ++++-- 10 files changed, 343 insertions(+), 124 deletions(-) diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 61dd355a..136c432d 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -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"}]} diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index cf757879..ab2dd63a 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -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 { diff --git a/cmd/bd/label.go b/cmd/bd/label.go index 68714be8..e0590e5c 100644 --- a/cmd/bd/label.go +++ b/cmd/bd/label.go @@ -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}) diff --git a/cmd/bd/reopen.go b/cmd/bd/reopen.go index f22890a8..f8863171 100644 --- a/cmd/bd/reopen.go +++ b/cmd/bd/reopen.go @@ -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, diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 596f67b5..0ed25f36 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -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) } } diff --git a/internal/rpc/client.go b/internal/rpc/client.go index 18a27a2f..d1257319 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -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) diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 54974db8..21881967 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -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"` diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 76879c17..42e0f253 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -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 { diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index 4f2cf21f..118a9090 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -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: diff --git a/internal/utils/id_parser.go b/internal/utils/id_parser.go index eefd8169..d52af578 100644 --- a/internal/utils/id_parser.go +++ b/internal/utils/id_parser.go @@ -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) } }