Merge branch 'steveyegge:main' into main

This commit is contained in:
matt wilkie
2025-11-13 13:18:45 -07:00
committed by GitHub
5 changed files with 263 additions and 127 deletions

View File

@@ -229,6 +229,7 @@ var createCmd = &cobra.Command{
Design: design, Design: design,
AcceptanceCriteria: acceptance, AcceptanceCriteria: acceptance,
Assignee: assignee, Assignee: assignee,
ExternalRef: externalRef,
Labels: labels, Labels: labels,
Dependencies: deps, Dependencies: deps,
} }

View File

@@ -1,4 +1,5 @@
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
@@ -6,36 +7,22 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils" "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{ var showCmd = &cobra.Command{
Use: "show [id...]", Use: "show [id...]",
Short: "Show issue details", Short: "Show issue details",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background() ctx := context.Background()
// Resolve partial IDs first // Resolve partial IDs first
var resolvedIDs []string var resolvedIDs []string
if daemonClient != nil { if daemonClient != nil {
@@ -47,12 +34,7 @@ var showCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1) os.Exit(1)
} }
var resolvedID string resolvedIDs = append(resolvedIDs, string(resp.Data))
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)
} }
} else { } else {
// In direct mode, resolve via storage // In direct mode, resolve via storage
@@ -63,6 +45,7 @@ var showCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
allDetails := []interface{}{} allDetails := []interface{}{}
@@ -73,12 +56,13 @@ var showCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue continue
} }
if jsonOutput { if jsonOutput {
type IssueDetails struct { type IssueDetails struct {
types.Issue types.Issue
Labels []string `json:"labels,omitempty"` Labels []string `json:"labels,omitempty"`
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"` Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"` Dependents []*types.Issue `json:"dependents,omitempty"`
} }
var details IssueDetails var details IssueDetails
if err := json.Unmarshal(resp.Data, &details); err == nil { if err := json.Unmarshal(resp.Data, &details); err == nil {
@@ -93,12 +77,13 @@ var showCmd = &cobra.Command{
if idx > 0 { if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60)) fmt.Println("\n" + strings.Repeat("─", 60))
} }
// Parse response and use existing formatting code // Parse response and use existing formatting code
type IssueDetails struct { type IssueDetails struct {
types.Issue types.Issue
Labels []string `json:"labels,omitempty"` Labels []string `json:"labels,omitempty"`
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"` Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"` Dependents []*types.Issue `json:"dependents,omitempty"`
} }
var details IssueDetails var details IssueDetails
if err := json.Unmarshal(resp.Data, &details); err != nil { if err := json.Unmarshal(resp.Data, &details); err != nil {
@@ -106,7 +91,9 @@ var showCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
issue := &details.Issue issue := &details.Issue
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
// Format output (same as direct mode below) // Format output (same as direct mode below)
tierEmoji := "" tierEmoji := ""
statusSuffix := "" statusSuffix := ""
@@ -118,6 +105,7 @@ var showCmd = &cobra.Command{
tierEmoji = " 📦" tierEmoji = " 📦"
statusSuffix = " (compacted L2)" statusSuffix = " (compacted L2)"
} }
fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
fmt.Printf("Priority: P%d\n", issue.Priority) 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("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04")) fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
// Show compaction status // Show compaction status
if issue.CompactionLevel > 0 { if issue.CompactionLevel > 0 {
fmt.Println() fmt.Println()
@@ -152,6 +141,7 @@ var showCmd = &cobra.Command{
} }
fmt.Printf("%s Compacted: %s (Tier %d)\n", tierEmoji2, compactedDate, issue.CompactionLevel) fmt.Printf("%s Compacted: %s (Tier %d)\n", tierEmoji2, compactedDate, issue.CompactionLevel)
} }
if issue.Description != "" { if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description) fmt.Printf("\nDescription:\n%s\n", issue.Description)
} }
@@ -164,33 +154,35 @@ var showCmd = &cobra.Command{
if issue.AcceptanceCriteria != "" { if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria) fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
} }
if len(details.Labels) > 0 { if len(details.Labels) > 0 {
fmt.Printf("\nLabels: %v\n", details.Labels) fmt.Printf("\nLabels: %v\n", details.Labels)
} }
if len(details.Dependencies) > 0 { 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 { for _, dep := range details.Dependencies {
fmt.Printf(" [%s] %s (%s): %s [P%d]\n", fmt.Printf(" %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
formatDependencyType(dep.DependencyType),
dep.ID, dep.Status, dep.Title, dep.Priority)
} }
} }
if len(details.Dependents) > 0 { 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 { for _, dep := range details.Dependents {
fmt.Printf(" [%s] %s (%s): %s [P%d]\n", fmt.Printf(" %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
formatDependencyType(dep.DependencyType),
dep.ID, dep.Status, dep.Title, dep.Priority)
} }
} }
fmt.Println() fmt.Println()
} }
} }
if jsonOutput && len(allDetails) > 0 { if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails) outputJSON(allDetails)
} }
return return
} }
// Direct mode // Direct mode
allDetails := []interface{}{} allDetails := []interface{}{}
for idx, id := range resolvedIDs { for idx, id := range resolvedIDs {
@@ -203,40 +195,31 @@ var showCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id) fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue continue
} }
if jsonOutput { 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 { type IssueDetails struct {
*types.Issue *types.Issue
Labels []string `json:"labels,omitempty"` Labels []string `json:"labels,omitempty"`
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"` Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"` Dependents []*types.Issue `json:"dependents,omitempty"`
Comments []*types.Comment `json:"comments,omitempty"` Comments []*types.Comment `json:"comments,omitempty"`
} }
details := &IssueDetails{Issue: issue} details := &IssueDetails{Issue: issue}
details.Labels, _ = store.GetLabels(ctx, issue.ID) details.Labels, _ = store.GetLabels(ctx, issue.ID)
// Get dependencies with metadata (type, created_at, created_by) details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { details.Dependents, _ = store.GetDependents(ctx, issue.ID)
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.Comments, _ = store.GetIssueComments(ctx, issue.ID) details.Comments, _ = store.GetIssueComments(ctx, issue.ID)
allDetails = append(allDetails, details) allDetails = append(allDetails, details)
continue continue
} }
if idx > 0 { if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60)) fmt.Println("\n" + strings.Repeat("─", 60))
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
// Add compaction emoji to title line // Add compaction emoji to title line
tierEmoji := "" tierEmoji := ""
statusSuffix := "" statusSuffix := ""
@@ -248,6 +231,7 @@ var showCmd = &cobra.Command{
tierEmoji = " 📦" tierEmoji = " 📦"
statusSuffix = " (compacted L2)" statusSuffix = " (compacted L2)"
} }
fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji) fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix) fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
fmt.Printf("Priority: P%d\n", issue.Priority) 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("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04")) fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
// Show compaction status footer // Show compaction status footer
if issue.CompactionLevel > 0 { if issue.CompactionLevel > 0 {
tierEmoji := "🗜️" tierEmoji := "🗜️"
@@ -267,6 +252,7 @@ var showCmd = &cobra.Command{
tierEmoji = "📦" tierEmoji = "📦"
} }
tierName := fmt.Sprintf("Tier %d", issue.CompactionLevel) tierName := fmt.Sprintf("Tier %d", issue.CompactionLevel)
fmt.Println() fmt.Println()
if issue.OriginalSize > 0 { if issue.OriginalSize > 0 {
currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria) 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) fmt.Printf("%s Compacted: %s (%s)\n", tierEmoji, compactedDate, tierName)
} }
if issue.Description != "" { if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description) fmt.Printf("\nDescription:\n%s\n", issue.Description)
} }
@@ -295,56 +282,31 @@ var showCmd = &cobra.Command{
if issue.AcceptanceCriteria != "" { if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria) fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
} }
// Show labels // Show labels
labels, _ := store.GetLabels(ctx, issue.ID) labels, _ := store.GetLabels(ctx, issue.ID)
if len(labels) > 0 { if len(labels) > 0 {
fmt.Printf("\nLabels: %v\n", labels) fmt.Printf("\nLabels: %v\n", labels)
} }
// Show dependencies with metadata (including type)
var depsWithMeta []*types.IssueWithDependencyMetadata // Show dependencies
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { deps, _ := store.GetDependencies(ctx, issue.ID)
depsWithMeta, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID) if len(deps) > 0 {
} else { fmt.Printf("\nDepends on (%d):\n", len(deps))
// Fallback for non-SQLite storage
deps, _ := store.GetDependencies(ctx, issue.ID)
for _, dep := range deps { for _, dep := range deps {
depsWithMeta = append(depsWithMeta, &types.IssueWithDependencyMetadata{ fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
Issue: *dep,
DependencyType: types.DepBlocks, // default
})
}
}
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) // Show dependents
var dependentsWithMeta []*types.IssueWithDependencyMetadata dependents, _ := store.GetDependents(ctx, issue.ID)
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { if len(dependents) > 0 {
dependentsWithMeta, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID) fmt.Printf("\nBlocks (%d):\n", len(dependents))
} else { for _, dep := range dependents {
// Fallback for non-SQLite storage fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
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 comments // Show comments
comments, _ := store.GetIssueComments(ctx, issue.ID) comments, _ := store.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 { 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.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text)
} }
} }
fmt.Println() fmt.Println()
} }
if jsonOutput && len(allDetails) > 0 { if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails) outputJSON(allDetails)
} }
}, },
} }
var updateCmd = &cobra.Command{ var updateCmd = &cobra.Command{
Use: "update [id...]", Use: "update [id...]",
Short: "Update one or more issues", Short: "Update one or more issues",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun jsonOutput, _ := cmd.Flags().GetBool("json")
updates := make(map[string]interface{}) updates := make(map[string]interface{})
if cmd.Flags().Changed("status") { if cmd.Flags().Changed("status") {
status, _ := cmd.Flags().GetString("status") status, _ := cmd.Flags().GetString("status")
updates["status"] = status updates["status"] = status
} }
if cmd.Flags().Changed("priority") { if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority") priority, _ := cmd.Flags().GetInt("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)
}
updates["priority"] = priority updates["priority"] = priority
} }
if cmd.Flags().Changed("title") { if cmd.Flags().Changed("title") {
@@ -413,11 +374,14 @@ var updateCmd = &cobra.Command{
externalRef, _ := cmd.Flags().GetString("external-ref") externalRef, _ := cmd.Flags().GetString("external-ref")
updates["external_ref"] = externalRef updates["external_ref"] = externalRef
} }
if len(updates) == 0 { if len(updates) == 0 {
fmt.Println("No updates specified") fmt.Println("No updates specified")
return return
} }
ctx := context.Background() ctx := context.Background()
// Resolve partial IDs first // Resolve partial IDs first
var resolvedIDs []string var resolvedIDs []string
if daemonClient != nil { if daemonClient != nil {
@@ -428,12 +392,7 @@ var updateCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1) os.Exit(1)
} }
var resolvedID string resolvedIDs = append(resolvedIDs, string(resp.Data))
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)
} }
} else { } else {
var err error var err error
@@ -443,11 +402,13 @@ var updateCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
updatedIssues := []*types.Issue{} updatedIssues := []*types.Issue{}
for _, id := range resolvedIDs { for _, id := range resolvedIDs {
updateArgs := &rpc.UpdateArgs{ID: id} updateArgs := &rpc.UpdateArgs{ID: id}
// Map updates to RPC args // Map updates to RPC args
if status, ok := updates["status"].(string); ok { if status, ok := updates["status"].(string); ok {
updateArgs.Status = &status updateArgs.Status = &status
@@ -473,11 +434,16 @@ var updateCmd = &cobra.Command{
if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok { if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok {
updateArgs.AcceptanceCriteria = &acceptanceCriteria updateArgs.AcceptanceCriteria = &acceptanceCriteria
} }
if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref
updateArgs.ExternalRef = &externalRef
}
resp, err := daemonClient.Update(updateArgs) resp, err := daemonClient.Update(updateArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue continue
} }
if jsonOutput { if jsonOutput {
var issue types.Issue var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil { 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) fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
} }
} }
if jsonOutput && len(updatedIssues) > 0 { if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues) outputJSON(updatedIssues)
} }
return return
} }
// Direct mode // Direct mode
updatedIssues := []*types.Issue{} updatedIssues := []*types.Issue{}
for _, id := range resolvedIDs { for _, id := range resolvedIDs {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil { if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue continue
} }
if jsonOutput {
if jsonOutput {
issue, _ := store.GetIssue(ctx, id) issue, _ := store.GetIssue(ctx, id)
if issue != nil { if issue != nil {
updatedIssues = append(updatedIssues, issue) updatedIssues = append(updatedIssues, issue)
@@ -510,20 +479,25 @@ var updateCmd = &cobra.Command{
fmt.Printf("%s Updated issue: %s\n", green("✓"), id) fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
} }
} }
// Schedule auto-flush if any issues were updated // Schedule auto-flush if any issues were updated
if len(args) > 0 { if len(args) > 0 {
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
if jsonOutput && len(updatedIssues) > 0 { if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues) outputJSON(updatedIssues)
} }
}, },
} }
var editCmd = &cobra.Command{ var editCmd = &cobra.Command{
Use: "edit [id]", Use: "edit [id]",
Short: "Edit an issue field in $EDITOR", Short: "Edit an issue field in $EDITOR",
Long: `Edit an issue field using your configured $EDITOR. Long: `Edit an issue field using your configured $EDITOR.
By default, edits the description. Use flags to edit other fields. By default, edits the description. Use flags to edit other fields.
Examples: Examples:
bd edit bd-42 # Edit description bd edit bd-42 # Edit description
bd edit bd-42 --title # Edit title bd edit bd-42 --title # Edit title
@@ -534,6 +508,7 @@ Examples:
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
id := args[0] id := args[0]
ctx := context.Background() ctx := context.Background()
// Resolve partial ID if in direct mode // Resolve partial ID if in direct mode
if daemonClient == nil { if daemonClient == nil {
fullID, err := utils.ResolvePartialID(ctx, store, id) fullID, err := utils.ResolvePartialID(ctx, store, id)
@@ -543,6 +518,7 @@ Examples:
} }
id = fullID id = fullID
} }
// Determine which field to edit // Determine which field to edit
fieldToEdit := "description" fieldToEdit := "description"
if cmd.Flags().Changed("title") { if cmd.Flags().Changed("title") {
@@ -554,6 +530,7 @@ Examples:
} else if cmd.Flags().Changed("acceptance") { } else if cmd.Flags().Changed("acceptance") {
fieldToEdit = "acceptance_criteria" fieldToEdit = "acceptance_criteria"
} }
// Get the editor from environment // Get the editor from environment
editor := os.Getenv("EDITOR") editor := os.Getenv("EDITOR")
if editor == "" { if editor == "" {
@@ -572,9 +549,11 @@ Examples:
fmt.Fprintf(os.Stderr, "Error: No editor found. Set $EDITOR or $VISUAL environment variable.\n") fmt.Fprintf(os.Stderr, "Error: No editor found. Set $EDITOR or $VISUAL environment variable.\n")
os.Exit(1) os.Exit(1)
} }
// Get the current issue // Get the current issue
var issue *types.Issue var issue *types.Issue
var err error var err error
if daemonClient != nil { if daemonClient != nil {
// Daemon mode // Daemon mode
showArgs := &rpc.ShowArgs{ID: id} showArgs := &rpc.ShowArgs{ID: id}
@@ -583,6 +562,7 @@ Examples:
fmt.Fprintf(os.Stderr, "Error fetching issue %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error fetching issue %s: %v\n", id, err)
os.Exit(1) os.Exit(1)
} }
issue = &types.Issue{} issue = &types.Issue{}
if err := json.Unmarshal(resp.Data, issue); err != nil { if err := json.Unmarshal(resp.Data, issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing issue data: %v\n", err) fmt.Fprintf(os.Stderr, "Error parsing issue data: %v\n", err)
@@ -600,6 +580,7 @@ Examples:
os.Exit(1) os.Exit(1)
} }
} }
// Get the current field value // Get the current field value
var currentValue string var currentValue string
switch fieldToEdit { switch fieldToEdit {
@@ -614,6 +595,7 @@ Examples:
case "acceptance_criteria": case "acceptance_criteria":
currentValue = issue.AcceptanceCriteria currentValue = issue.AcceptanceCriteria
} }
// Create a temporary file with the current value // Create a temporary file with the current value
tmpFile, err := os.CreateTemp("", fmt.Sprintf("bd-edit-%s-*.txt", fieldToEdit)) tmpFile, err := os.CreateTemp("", fmt.Sprintf("bd-edit-%s-*.txt", fieldToEdit))
if err != nil { if err != nil {
@@ -622,47 +604,56 @@ Examples:
} }
tmpPath := tmpFile.Name() tmpPath := tmpFile.Name()
defer func() { _ = os.Remove(tmpPath) }() defer func() { _ = os.Remove(tmpPath) }()
// Write current value to temp file // Write current value to temp file
if _, err := tmpFile.WriteString(currentValue); err != nil { 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) fmt.Fprintf(os.Stderr, "Error writing to temp file: %v\n", err)
os.Exit(1) os.Exit(1)
} }
_ = tmpFile.Close() // nolint:gosec // G104: Defer close errors are non-critical tmpFile.Close()
// Open the editor // Open the editor
editorCmd := exec.Command(editor, tmpPath) editorCmd := exec.Command(editor, tmpPath)
editorCmd.Stdin = os.Stdin editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil { if err := editorCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error running editor: %v\n", err) fmt.Fprintf(os.Stderr, "Error running editor: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Read the edited content // Read the edited content
// nolint:gosec // G304: tmpPath is securely created temp file
editedContent, err := os.ReadFile(tmpPath) editedContent, err := os.ReadFile(tmpPath)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err) fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err)
os.Exit(1) os.Exit(1)
} }
newValue := string(editedContent) newValue := string(editedContent)
// Check if the value changed // Check if the value changed
if newValue == currentValue { if newValue == currentValue {
fmt.Println("No changes made") fmt.Println("No changes made")
return return
} }
// Validate title if editing title // Validate title if editing title
if fieldToEdit == "title" && strings.TrimSpace(newValue) == "" { if fieldToEdit == "title" && strings.TrimSpace(newValue) == "" {
fmt.Fprintf(os.Stderr, "Error: title cannot be empty\n") fmt.Fprintf(os.Stderr, "Error: title cannot be empty\n")
os.Exit(1) os.Exit(1)
} }
// Update the issue // Update the issue
updates := map[string]interface{}{ updates := map[string]interface{}{
fieldToEdit: newValue, fieldToEdit: newValue,
} }
if daemonClient != nil { if daemonClient != nil {
// Daemon mode // Daemon mode
updateArgs := &rpc.UpdateArgs{ID: id} updateArgs := &rpc.UpdateArgs{ID: id}
switch fieldToEdit { switch fieldToEdit {
case "title": case "title":
updateArgs.Title = &newValue updateArgs.Title = &newValue
@@ -675,6 +666,7 @@ Examples:
case "acceptance_criteria": case "acceptance_criteria":
updateArgs.AcceptanceCriteria = &newValue updateArgs.AcceptanceCriteria = &newValue
} }
_, err := daemonClient.Update(updateArgs) _, err := daemonClient.Update(updateArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error updating issue: %v\n", err) fmt.Fprintf(os.Stderr, "Error updating issue: %v\n", err)
@@ -688,11 +680,13 @@ Examples:
} }
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
fieldName := strings.ReplaceAll(fieldToEdit, "_", " ") fieldName := strings.ReplaceAll(fieldToEdit, "_", " ")
fmt.Printf("%s Updated %s for issue: %s\n", green("✓"), fieldName, id) fmt.Printf("%s Updated %s for issue: %s\n", green("✓"), fieldName, id)
}, },
} }
var closeCmd = &cobra.Command{ var closeCmd = &cobra.Command{
Use: "close [id...]", Use: "close [id...]",
Short: "Close one or more issues", Short: "Close one or more issues",
@@ -702,8 +696,10 @@ var closeCmd = &cobra.Command{
if reason == "" { if reason == "" {
reason = "Closed" reason = "Closed"
} }
// Use global jsonOutput set by PersistentPreRun jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background() ctx := context.Background()
// Resolve partial IDs first // Resolve partial IDs first
var resolvedIDs []string var resolvedIDs []string
if daemonClient != nil { if daemonClient != nil {
@@ -714,12 +710,7 @@ var closeCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1) os.Exit(1)
} }
var resolvedID string resolvedIDs = append(resolvedIDs, string(resp.Data))
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)
} }
} else { } else {
var err error var err error
@@ -729,6 +720,7 @@ var closeCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
closedIssues := []*types.Issue{} closedIssues := []*types.Issue{}
@@ -742,6 +734,7 @@ var closeCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
continue continue
} }
if jsonOutput { if jsonOutput {
var issue types.Issue var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil { 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) fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
} }
} }
if jsonOutput && len(closedIssues) > 0 { if jsonOutput && len(closedIssues) > 0 {
outputJSON(closedIssues) outputJSON(closedIssues)
} }
return return
} }
// Direct mode // Direct mode
closedIssues := []*types.Issue{} closedIssues := []*types.Issue{}
for _, id := range resolvedIDs { for _, id := range resolvedIDs {
@@ -774,19 +769,24 @@ var closeCmd = &cobra.Command{
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason) fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
} }
} }
// Schedule auto-flush if any issues were closed // Schedule auto-flush if any issues were closed
if len(args) > 0 { if len(args) > 0 {
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
if jsonOutput && len(closedIssues) > 0 { if jsonOutput && len(closedIssues) > 0 {
outputJSON(closedIssues) outputJSON(closedIssues)
} }
}, },
} }
func init() { func init() {
showCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(showCmd) rootCmd.AddCommand(showCmd)
updateCmd.Flags().StringP("status", "s", "", "New status") 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().String("title", "", "New title")
updateCmd.Flags().StringP("assignee", "a", "", "New assignee") updateCmd.Flags().StringP("assignee", "a", "", "New assignee")
updateCmd.Flags().StringP("description", "d", "", "Issue description") updateCmd.Flags().StringP("description", "d", "", "Issue description")
@@ -796,13 +796,17 @@ func init() {
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance") updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")
_ = updateCmd.Flags().MarkHidden("acceptance-criteria") _ = updateCmd.Flags().MarkHidden("acceptance-criteria")
updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
updateCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateCmd)
editCmd.Flags().Bool("title", false, "Edit the title") editCmd.Flags().Bool("title", false, "Edit the title")
editCmd.Flags().Bool("description", false, "Edit the description (default)") editCmd.Flags().Bool("description", false, "Edit the description (default)")
editCmd.Flags().Bool("design", false, "Edit the design notes") editCmd.Flags().Bool("design", false, "Edit the design notes")
editCmd.Flags().Bool("notes", false, "Edit the notes") editCmd.Flags().Bool("notes", false, "Edit the notes")
editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria") editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria")
rootCmd.AddCommand(editCmd) rootCmd.AddCommand(editCmd)
closeCmd.Flags().StringP("reason", "r", "", "Reason for closing") closeCmd.Flags().StringP("reason", "r", "", "Reason for closing")
closeCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(closeCmd) rootCmd.AddCommand(closeCmd)
} }

View File

@@ -66,6 +66,7 @@ type CreateArgs struct {
Design string `json:"design,omitempty"` Design string `json:"design,omitempty"`
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"` AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
Assignee string `json:"assignee,omitempty"` Assignee string `json:"assignee,omitempty"`
ExternalRef string `json:"external_ref,omitempty"` // Link to external issue trackers
Labels []string `json:"labels,omitempty"` Labels []string `json:"labels,omitempty"`
Dependencies []string `json:"dependencies,omitempty"` Dependencies []string `json:"dependencies,omitempty"`
} }
@@ -81,6 +82,7 @@ type UpdateArgs struct {
AcceptanceCriteria *string `json:"acceptance_criteria,omitempty"` AcceptanceCriteria *string `json:"acceptance_criteria,omitempty"`
Notes *string `json:"notes,omitempty"` Notes *string `json:"notes,omitempty"`
Assignee *string `json:"assignee,omitempty"` Assignee *string `json:"assignee,omitempty"`
ExternalRef *string `json:"external_ref,omitempty"` // Link to external issue trackers
} }
// CloseArgs represents arguments for the close operation // CloseArgs represents arguments for the close operation

View File

@@ -710,3 +710,125 @@ func TestCreate_DiscoveredFromInheritsSourceRepo(t *testing.T) {
// The logic is implemented in server_issues_epics.go handleCreate // The logic is implemented in server_issues_epics.go handleCreate
// and tested via the cmd/bd test which has direct storage access // 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
}

View File

@@ -66,6 +66,9 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} {
if a.Assignee != nil { if a.Assignee != nil {
u["assignee"] = *a.Assignee u["assignee"] = *a.Assignee
} }
if a.ExternalRef != nil {
u["external_ref"] = *a.ExternalRef
}
return u return u
} }
@@ -108,7 +111,7 @@ func (s *Server) handleCreate(req *Request) Response {
issueID = childID issueID = childID
} }
var design, acceptance, assignee *string var design, acceptance, assignee, externalRef *string
if createArgs.Design != "" { if createArgs.Design != "" {
design = &createArgs.Design design = &createArgs.Design
} }
@@ -118,6 +121,9 @@ func (s *Server) handleCreate(req *Request) Response {
if createArgs.Assignee != "" { if createArgs.Assignee != "" {
assignee = &createArgs.Assignee assignee = &createArgs.Assignee
} }
if createArgs.ExternalRef != "" {
externalRef = &createArgs.ExternalRef
}
issue := &types.Issue{ issue := &types.Issue{
ID: issueID, ID: issueID,
@@ -128,6 +134,7 @@ func (s *Server) handleCreate(req *Request) Response {
Design: strValue(design), Design: strValue(design),
AcceptanceCriteria: strValue(acceptance), AcceptanceCriteria: strValue(acceptance),
Assignee: strValue(assignee), Assignee: strValue(assignee),
ExternalRef: externalRef,
Status: types.StatusOpen, Status: types.StatusOpen,
} }