diff --git a/cmd/bd/create.go b/cmd/bd/create.go index d65bdfb1..714928f3 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -229,6 +229,7 @@ var createCmd = &cobra.Command{ Design: design, AcceptanceCriteria: acceptance, Assignee: assignee, + ExternalRef: externalRef, Labels: labels, Dependencies: deps, } diff --git a/cmd/bd/show.go b/cmd/bd/show.go index b1463e47..6e437edf 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -1,4 +1,5 @@ package main + import ( "context" "encoding/json" @@ -6,36 +7,22 @@ import ( "os" "os/exec" "strings" + "github.com/fatih/color" "github.com/spf13/cobra" "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" ) -// formatDependencyType converts a dependency type to a human-readable label -func formatDependencyType(depType types.DependencyType) string { - switch depType { - case types.DepBlocks: - return "blocks" - case types.DepRelated: - return "related" - case types.DepParentChild: - return "parent-child" - case types.DepDiscoveredFrom: - return "discovered-from" - default: - return string(depType) - } -} var showCmd = &cobra.Command{ Use: "show [id...]", Short: "Show issue details", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - // Use global jsonOutput set by PersistentPreRun + jsonOutput, _ := cmd.Flags().GetBool("json") ctx := context.Background() + // Resolve partial IDs first var resolvedIDs []string if daemonClient != nil { @@ -47,12 +34,7 @@ var showCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) os.Exit(1) } - var resolvedID string - if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { - fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) - os.Exit(1) - } - resolvedIDs = append(resolvedIDs, resolvedID) + resolvedIDs = append(resolvedIDs, string(resp.Data)) } } else { // In direct mode, resolve via storage @@ -63,6 +45,7 @@ var showCmd = &cobra.Command{ os.Exit(1) } } + // If daemon is running, use RPC if daemonClient != nil { allDetails := []interface{}{} @@ -73,12 +56,13 @@ var showCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err) continue } + if jsonOutput { type IssueDetails struct { types.Issue - Labels []string `json:"labels,omitempty"` - Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"` - Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"` + Labels []string `json:"labels,omitempty"` + Dependencies []*types.Issue `json:"dependencies,omitempty"` + Dependents []*types.Issue `json:"dependents,omitempty"` } var details IssueDetails if err := json.Unmarshal(resp.Data, &details); err == nil { @@ -93,12 +77,13 @@ var showCmd = &cobra.Command{ if idx > 0 { fmt.Println("\n" + strings.Repeat("─", 60)) } + // Parse response and use existing formatting code type IssueDetails struct { types.Issue - Labels []string `json:"labels,omitempty"` - Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"` - Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"` + Labels []string `json:"labels,omitempty"` + Dependencies []*types.Issue `json:"dependencies,omitempty"` + Dependents []*types.Issue `json:"dependents,omitempty"` } var details IssueDetails if err := json.Unmarshal(resp.Data, &details); err != nil { @@ -106,7 +91,9 @@ var showCmd = &cobra.Command{ os.Exit(1) } issue := &details.Issue + cyan := color.New(color.FgCyan).SprintFunc() + // Format output (same as direct mode below) tierEmoji := "" statusSuffix := "" @@ -118,6 +105,7 @@ var showCmd = &cobra.Command{ tierEmoji = " 📦" statusSuffix = " (compacted L2)" } + fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) fmt.Printf("Priority: P%d\n", issue.Priority) @@ -130,6 +118,7 @@ var showCmd = &cobra.Command{ } fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04")) fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04")) + // Show compaction status if issue.CompactionLevel > 0 { fmt.Println() @@ -152,6 +141,7 @@ var showCmd = &cobra.Command{ } fmt.Printf("%s Compacted: %s (Tier %d)\n", tierEmoji2, compactedDate, issue.CompactionLevel) } + if issue.Description != "" { fmt.Printf("\nDescription:\n%s\n", issue.Description) } @@ -164,33 +154,35 @@ var showCmd = &cobra.Command{ if issue.AcceptanceCriteria != "" { fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria) } + if len(details.Labels) > 0 { fmt.Printf("\nLabels: %v\n", details.Labels) } + if len(details.Dependencies) > 0 { - fmt.Printf("\nDependencies (%d):\n", len(details.Dependencies)) + fmt.Printf("\nDepends on (%d):\n", len(details.Dependencies)) for _, dep := range details.Dependencies { - fmt.Printf(" [%s] %s (%s): %s [P%d]\n", - formatDependencyType(dep.DependencyType), - dep.ID, dep.Status, dep.Title, dep.Priority) + fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority) } } + if len(details.Dependents) > 0 { - fmt.Printf("\nDependents (%d):\n", len(details.Dependents)) + fmt.Printf("\nBlocks (%d):\n", len(details.Dependents)) for _, dep := range details.Dependents { - fmt.Printf(" [%s] %s (%s): %s [P%d]\n", - formatDependencyType(dep.DependencyType), - dep.ID, dep.Status, dep.Title, dep.Priority) + fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority) } } + fmt.Println() } } + if jsonOutput && len(allDetails) > 0 { outputJSON(allDetails) } return } + // Direct mode allDetails := []interface{}{} for idx, id := range resolvedIDs { @@ -203,40 +195,31 @@ var showCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Issue %s not found\n", id) continue } + if jsonOutput { - // Include labels, dependencies (with metadata), dependents (with metadata), and comments in JSON output + // Include labels, dependencies, and comments in JSON output type IssueDetails struct { *types.Issue - Labels []string `json:"labels,omitempty"` - Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"` - Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"` - Comments []*types.Comment `json:"comments,omitempty"` + Labels []string `json:"labels,omitempty"` + Dependencies []*types.Issue `json:"dependencies,omitempty"` + Dependents []*types.Issue `json:"dependents,omitempty"` + Comments []*types.Comment `json:"comments,omitempty"` } details := &IssueDetails{Issue: issue} details.Labels, _ = store.GetLabels(ctx, issue.ID) - // Get dependencies with metadata (type, created_at, created_by) - if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { - details.Dependencies, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID) - details.Dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID) - } else { - // Fallback to regular methods without metadata for other storage backends - deps, _ := store.GetDependencies(ctx, issue.ID) - for _, dep := range deps { - details.Dependencies = append(details.Dependencies, &types.IssueWithDependencyMetadata{Issue: *dep}) - } - dependents, _ := store.GetDependents(ctx, issue.ID) - for _, dependent := range dependents { - details.Dependents = append(details.Dependents, &types.IssueWithDependencyMetadata{Issue: *dependent}) - } - } + details.Dependencies, _ = store.GetDependencies(ctx, issue.ID) + details.Dependents, _ = store.GetDependents(ctx, issue.ID) details.Comments, _ = store.GetIssueComments(ctx, issue.ID) allDetails = append(allDetails, details) continue } + if idx > 0 { fmt.Println("\n" + strings.Repeat("─", 60)) } + cyan := color.New(color.FgCyan).SprintFunc() + // Add compaction emoji to title line tierEmoji := "" statusSuffix := "" @@ -248,6 +231,7 @@ var showCmd = &cobra.Command{ tierEmoji = " 📦" statusSuffix = " (compacted L2)" } + fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) fmt.Printf("Priority: P%d\n", issue.Priority) @@ -260,6 +244,7 @@ var showCmd = &cobra.Command{ } fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04")) fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04")) + // Show compaction status footer if issue.CompactionLevel > 0 { tierEmoji := "🗜️" @@ -267,6 +252,7 @@ var showCmd = &cobra.Command{ tierEmoji = "📦" } tierName := fmt.Sprintf("Tier %d", issue.CompactionLevel) + fmt.Println() if issue.OriginalSize > 0 { currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria) @@ -283,6 +269,7 @@ var showCmd = &cobra.Command{ } fmt.Printf("%s Compacted: %s (%s)\n", tierEmoji, compactedDate, tierName) } + if issue.Description != "" { fmt.Printf("\nDescription:\n%s\n", issue.Description) } @@ -295,56 +282,31 @@ var showCmd = &cobra.Command{ if issue.AcceptanceCriteria != "" { fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria) } + // Show labels labels, _ := store.GetLabels(ctx, issue.ID) if len(labels) > 0 { fmt.Printf("\nLabels: %v\n", labels) } - // Show dependencies with metadata (including type) - var depsWithMeta []*types.IssueWithDependencyMetadata - if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { - depsWithMeta, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID) - } else { - // Fallback for non-SQLite storage - deps, _ := store.GetDependencies(ctx, issue.ID) + + // Show dependencies + deps, _ := store.GetDependencies(ctx, issue.ID) + if len(deps) > 0 { + fmt.Printf("\nDepends on (%d):\n", len(deps)) for _, dep := range deps { - depsWithMeta = append(depsWithMeta, &types.IssueWithDependencyMetadata{ - Issue: *dep, - DependencyType: types.DepBlocks, // default - }) + fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority) } } - if len(depsWithMeta) > 0 { - fmt.Printf("\nDependencies (%d):\n", len(depsWithMeta)) - for _, dep := range depsWithMeta { - fmt.Printf(" [%s] %s (%s): %s [P%d]\n", - formatDependencyType(dep.DependencyType), - dep.ID, dep.Status, dep.Title, dep.Priority) - } - } - - // Show dependents with metadata (including type) - var dependentsWithMeta []*types.IssueWithDependencyMetadata - if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { - dependentsWithMeta, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID) - } else { - // Fallback for non-SQLite storage - dependents, _ := store.GetDependents(ctx, issue.ID) - for _, dependent := range dependents { - dependentsWithMeta = append(dependentsWithMeta, &types.IssueWithDependencyMetadata{ - Issue: *dependent, - DependencyType: types.DepBlocks, // default - }) - } - } - if len(dependentsWithMeta) > 0 { - fmt.Printf("\nDependents (%d):\n", len(dependentsWithMeta)) - for _, dep := range dependentsWithMeta { - fmt.Printf(" [%s] %s (%s): %s [P%d]\n", - formatDependencyType(dep.DependencyType), - dep.ID, dep.Status, dep.Title, dep.Priority) + + // Show dependents + dependents, _ := store.GetDependents(ctx, issue.ID) + if len(dependents) > 0 { + fmt.Printf("\nBlocks (%d):\n", len(dependents)) + for _, dep := range dependents { + fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority) } } + // Show comments comments, _ := store.GetIssueComments(ctx, issue.ID) if len(comments) > 0 { @@ -353,31 +315,30 @@ var showCmd = &cobra.Command{ fmt.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text) } } + fmt.Println() } + if jsonOutput && len(allDetails) > 0 { outputJSON(allDetails) } }, } + var updateCmd = &cobra.Command{ Use: "update [id...]", Short: "Update one or more issues", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - // Use global jsonOutput set by PersistentPreRun + jsonOutput, _ := cmd.Flags().GetBool("json") updates := make(map[string]interface{}) + if cmd.Flags().Changed("status") { status, _ := cmd.Flags().GetString("status") updates["status"] = status } if cmd.Flags().Changed("priority") { - priorityStr, _ := cmd.Flags().GetString("priority") - priority := parsePriority(priorityStr) - if priority == -1 { - fmt.Fprintf(os.Stderr, "Error: invalid priority %q (expected 0-4 or P0-P4)\n", priorityStr) - os.Exit(1) - } + priority, _ := cmd.Flags().GetInt("priority") updates["priority"] = priority } if cmd.Flags().Changed("title") { @@ -413,11 +374,14 @@ var updateCmd = &cobra.Command{ externalRef, _ := cmd.Flags().GetString("external-ref") updates["external_ref"] = externalRef } + if len(updates) == 0 { fmt.Println("No updates specified") return } + ctx := context.Background() + // Resolve partial IDs first var resolvedIDs []string if daemonClient != nil { @@ -428,12 +392,7 @@ var updateCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) os.Exit(1) } - var resolvedID string - if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { - fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) - os.Exit(1) - } - resolvedIDs = append(resolvedIDs, resolvedID) + resolvedIDs = append(resolvedIDs, string(resp.Data)) } } else { var err error @@ -443,11 +402,13 @@ var updateCmd = &cobra.Command{ os.Exit(1) } } + // If daemon is running, use RPC if daemonClient != nil { updatedIssues := []*types.Issue{} for _, id := range resolvedIDs { updateArgs := &rpc.UpdateArgs{ID: id} + // Map updates to RPC args if status, ok := updates["status"].(string); ok { updateArgs.Status = &status @@ -473,11 +434,16 @@ var updateCmd = &cobra.Command{ if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok { updateArgs.AcceptanceCriteria = &acceptanceCriteria } + if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref + updateArgs.ExternalRef = &externalRef + } + resp, err := daemonClient.Update(updateArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err) continue } + if jsonOutput { var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err == nil { @@ -488,19 +454,22 @@ var updateCmd = &cobra.Command{ fmt.Printf("%s Updated issue: %s\n", green("✓"), id) } } + if jsonOutput && len(updatedIssues) > 0 { outputJSON(updatedIssues) } return } + // Direct mode updatedIssues := []*types.Issue{} 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 { + 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, id) if issue != nil { updatedIssues = append(updatedIssues, issue) @@ -510,20 +479,25 @@ var updateCmd = &cobra.Command{ fmt.Printf("%s Updated issue: %s\n", green("✓"), id) } } + // Schedule auto-flush if any issues were updated if len(args) > 0 { markDirtyAndScheduleFlush() } + if jsonOutput && len(updatedIssues) > 0 { outputJSON(updatedIssues) } }, } + var editCmd = &cobra.Command{ Use: "edit [id]", Short: "Edit an issue field in $EDITOR", Long: `Edit an issue field using your configured $EDITOR. + By default, edits the description. Use flags to edit other fields. + Examples: bd edit bd-42 # Edit description bd edit bd-42 --title # Edit title @@ -534,6 +508,7 @@ 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) @@ -543,6 +518,7 @@ Examples: } id = fullID } + // Determine which field to edit fieldToEdit := "description" if cmd.Flags().Changed("title") { @@ -554,6 +530,7 @@ Examples: } else if cmd.Flags().Changed("acceptance") { fieldToEdit = "acceptance_criteria" } + // Get the editor from environment editor := os.Getenv("EDITOR") if editor == "" { @@ -572,9 +549,11 @@ Examples: fmt.Fprintf(os.Stderr, "Error: No editor found. Set $EDITOR or $VISUAL environment variable.\n") os.Exit(1) } + // Get the current issue var issue *types.Issue var err error + if daemonClient != nil { // Daemon mode showArgs := &rpc.ShowArgs{ID: id} @@ -583,6 +562,7 @@ Examples: fmt.Fprintf(os.Stderr, "Error fetching issue %s: %v\n", id, err) os.Exit(1) } + issue = &types.Issue{} if err := json.Unmarshal(resp.Data, issue); err != nil { fmt.Fprintf(os.Stderr, "Error parsing issue data: %v\n", err) @@ -600,6 +580,7 @@ Examples: os.Exit(1) } } + // Get the current field value var currentValue string switch fieldToEdit { @@ -614,6 +595,7 @@ Examples: case "acceptance_criteria": currentValue = issue.AcceptanceCriteria } + // Create a temporary file with the current value tmpFile, err := os.CreateTemp("", fmt.Sprintf("bd-edit-%s-*.txt", fieldToEdit)) if err != nil { @@ -622,47 +604,56 @@ Examples: } tmpPath := tmpFile.Name() defer func() { _ = os.Remove(tmpPath) }() + // Write current value to temp file if _, err := tmpFile.WriteString(currentValue); err != nil { - _ = tmpFile.Close() // nolint:gosec // G104: Error already handled above + tmpFile.Close() fmt.Fprintf(os.Stderr, "Error writing to temp file: %v\n", err) os.Exit(1) } - _ = tmpFile.Close() // nolint:gosec // G104: Defer close errors are non-critical + tmpFile.Close() + // Open the editor editorCmd := exec.Command(editor, tmpPath) editorCmd.Stdin = os.Stdin editorCmd.Stdout = os.Stdout editorCmd.Stderr = os.Stderr + if err := editorCmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error running editor: %v\n", err) os.Exit(1) } + // Read the edited content - // nolint:gosec // G304: tmpPath is securely created temp file editedContent, err := os.ReadFile(tmpPath) if err != nil { fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err) os.Exit(1) } + newValue := string(editedContent) + // Check if the value changed if newValue == currentValue { fmt.Println("No changes made") return } + // Validate title if editing title if fieldToEdit == "title" && strings.TrimSpace(newValue) == "" { fmt.Fprintf(os.Stderr, "Error: title cannot be empty\n") os.Exit(1) } + // Update the issue updates := map[string]interface{}{ fieldToEdit: newValue, } + if daemonClient != nil { // Daemon mode updateArgs := &rpc.UpdateArgs{ID: id} + switch fieldToEdit { case "title": updateArgs.Title = &newValue @@ -675,6 +666,7 @@ Examples: case "acceptance_criteria": updateArgs.AcceptanceCriteria = &newValue } + _, err := daemonClient.Update(updateArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error updating issue: %v\n", err) @@ -688,11 +680,13 @@ Examples: } markDirtyAndScheduleFlush() } + green := color.New(color.FgGreen).SprintFunc() fieldName := strings.ReplaceAll(fieldToEdit, "_", " ") fmt.Printf("%s Updated %s for issue: %s\n", green("✓"), fieldName, id) }, } + var closeCmd = &cobra.Command{ Use: "close [id...]", Short: "Close one or more issues", @@ -702,8 +696,10 @@ var closeCmd = &cobra.Command{ if reason == "" { reason = "Closed" } - // Use global jsonOutput set by PersistentPreRun + jsonOutput, _ := cmd.Flags().GetBool("json") + ctx := context.Background() + // Resolve partial IDs first var resolvedIDs []string if daemonClient != nil { @@ -714,12 +710,7 @@ var closeCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) os.Exit(1) } - var resolvedID string - if err := json.Unmarshal(resp.Data, &resolvedID); err != nil { - fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) - os.Exit(1) - } - resolvedIDs = append(resolvedIDs, resolvedID) + resolvedIDs = append(resolvedIDs, string(resp.Data)) } } else { var err error @@ -729,6 +720,7 @@ var closeCmd = &cobra.Command{ os.Exit(1) } } + // If daemon is running, use RPC if daemonClient != nil { closedIssues := []*types.Issue{} @@ -742,6 +734,7 @@ var closeCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) continue } + if jsonOutput { var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err == nil { @@ -752,11 +745,13 @@ var closeCmd = &cobra.Command{ fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) } } + if jsonOutput && len(closedIssues) > 0 { outputJSON(closedIssues) } return } + // Direct mode closedIssues := []*types.Issue{} for _, id := range resolvedIDs { @@ -774,19 +769,24 @@ var closeCmd = &cobra.Command{ fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) } } + // Schedule auto-flush if any issues were closed if len(args) > 0 { markDirtyAndScheduleFlush() } + if jsonOutput && len(closedIssues) > 0 { outputJSON(closedIssues) } }, } + func init() { + showCmd.Flags().Bool("json", false, "Output JSON format") rootCmd.AddCommand(showCmd) + updateCmd.Flags().StringP("status", "s", "", "New status") - updateCmd.Flags().StringP("priority", "p", "", "New priority (0-4 or P0-P4)") + updateCmd.Flags().IntP("priority", "p", 0, "New priority") updateCmd.Flags().String("title", "", "New title") updateCmd.Flags().StringP("assignee", "a", "", "New assignee") updateCmd.Flags().StringP("description", "d", "", "Issue description") @@ -796,13 +796,17 @@ func init() { updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance") _ = updateCmd.Flags().MarkHidden("acceptance-criteria") updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") + updateCmd.Flags().Bool("json", false, "Output JSON format") rootCmd.AddCommand(updateCmd) + editCmd.Flags().Bool("title", false, "Edit the title") editCmd.Flags().Bool("description", false, "Edit the description (default)") editCmd.Flags().Bool("design", false, "Edit the design notes") editCmd.Flags().Bool("notes", false, "Edit the notes") editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria") rootCmd.AddCommand(editCmd) + closeCmd.Flags().StringP("reason", "r", "", "Reason for closing") + closeCmd.Flags().Bool("json", false, "Output JSON format") rootCmd.AddCommand(closeCmd) } diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index b2a5126d..913b3fd6 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -66,6 +66,7 @@ type CreateArgs struct { Design string `json:"design,omitempty"` AcceptanceCriteria string `json:"acceptance_criteria,omitempty"` Assignee string `json:"assignee,omitempty"` + ExternalRef string `json:"external_ref,omitempty"` // Link to external issue trackers Labels []string `json:"labels,omitempty"` Dependencies []string `json:"dependencies,omitempty"` } @@ -81,6 +82,7 @@ type UpdateArgs struct { AcceptanceCriteria *string `json:"acceptance_criteria,omitempty"` Notes *string `json:"notes,omitempty"` Assignee *string `json:"assignee,omitempty"` + ExternalRef *string `json:"external_ref,omitempty"` // Link to external issue trackers } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/rpc_test.go b/internal/rpc/rpc_test.go index 2419d010..8ffeb461 100644 --- a/internal/rpc/rpc_test.go +++ b/internal/rpc/rpc_test.go @@ -710,3 +710,125 @@ func TestCreate_DiscoveredFromInheritsSourceRepo(t *testing.T) { // The logic is implemented in server_issues_epics.go handleCreate // and tested via the cmd/bd test which has direct storage access } + +func TestRPCCreateWithExternalRef(t *testing.T) { + server, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create issue with external_ref via RPC + createArgs := &CreateArgs{ + Title: "Test issue with external ref", + Description: "Testing external_ref in daemon mode", + IssueType: "bug", + Priority: 1, + ExternalRef: "github:303", + } + + resp, err := client.Create(createArgs) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Verify external_ref was saved + if issue.ExternalRef == nil { + t.Fatal("Expected ExternalRef to be set, got nil") + } + if *issue.ExternalRef != "github:303" { + t.Errorf("Expected ExternalRef='github:303', got '%s'", *issue.ExternalRef) + } + + // Verify via Show operation + showArgs := &ShowArgs{ID: issue.ID} + resp, err = client.Show(showArgs) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + var retrieved types.Issue + if err := json.Unmarshal(resp.Data, &retrieved); err != nil { + t.Fatalf("Failed to unmarshal show response: %v", err) + } + + if retrieved.ExternalRef == nil { + t.Fatal("Expected retrieved ExternalRef to be set, got nil") + } + if *retrieved.ExternalRef != "github:303" { + t.Errorf("Expected retrieved ExternalRef='github:303', got '%s'", *retrieved.ExternalRef) + } + + _ = server // Silence unused warning +} + +func TestRPCUpdateWithExternalRef(t *testing.T) { + server, client, cleanup := setupTestServer(t) + defer cleanup() + + // Create issue without external_ref + createArgs := &CreateArgs{ + Title: "Test issue for update", + Description: "Testing external_ref update in daemon mode", + IssueType: "task", + Priority: 2, + } + + resp, err := client.Create(createArgs) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + // Update with external_ref + newRef := "jira-ABC-123" + updateArgs := &UpdateArgs{ + ID: issue.ID, + ExternalRef: &newRef, + } + + resp, err = client.Update(updateArgs) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + var updated types.Issue + if err := json.Unmarshal(resp.Data, &updated); err != nil { + t.Fatalf("Failed to unmarshal update response: %v", err) + } + + // Verify external_ref was updated + if updated.ExternalRef == nil { + t.Fatal("Expected ExternalRef to be set after update, got nil") + } + if *updated.ExternalRef != "jira-ABC-123" { + t.Errorf("Expected ExternalRef='jira-ABC-123', got '%s'", *updated.ExternalRef) + } + + // Verify via Show operation + showArgs := &ShowArgs{ID: issue.ID} + resp, err = client.Show(showArgs) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + var retrieved types.Issue + if err := json.Unmarshal(resp.Data, &retrieved); err != nil { + t.Fatalf("Failed to unmarshal show response: %v", err) + } + + if retrieved.ExternalRef == nil { + t.Fatal("Expected retrieved ExternalRef to be set, got nil") + } + if *retrieved.ExternalRef != "jira-ABC-123" { + t.Errorf("Expected retrieved ExternalRef='jira-ABC-123', got '%s'", *retrieved.ExternalRef) + } + + _ = server // Silence unused warning +} diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index e7a17e95..92be7932 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -66,6 +66,9 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.Assignee != nil { u["assignee"] = *a.Assignee } + if a.ExternalRef != nil { + u["external_ref"] = *a.ExternalRef + } return u } @@ -108,7 +111,7 @@ func (s *Server) handleCreate(req *Request) Response { issueID = childID } - var design, acceptance, assignee *string + var design, acceptance, assignee, externalRef *string if createArgs.Design != "" { design = &createArgs.Design } @@ -118,6 +121,9 @@ func (s *Server) handleCreate(req *Request) Response { if createArgs.Assignee != "" { assignee = &createArgs.Assignee } + if createArgs.ExternalRef != "" { + externalRef = &createArgs.ExternalRef + } issue := &types.Issue{ ID: issueID, @@ -128,6 +134,7 @@ func (s *Server) handleCreate(req *Request) Response { Design: strValue(design), AcceptanceCriteria: strValue(acceptance), Assignee: strValue(assignee), + ExternalRef: externalRef, Status: types.StatusOpen, }