Fix --json flag shadowing issue causing test failures

Fixed TestHashIDs_IdenticalContentDedup test failure by removing duplicate
--json flag definitions that were shadowing the global persistent flag.

Root cause: Commands had both a persistent --json flag (main.go) and local
--json flags (in individual command files). The local flags shadowed the
persistent flag, preventing jsonOutput variable from being set correctly.

Changes:
- Removed 31 duplicate --json flag definitions from 15 command files
- All commands now use the single persistent --json flag from main.go
- Commands now correctly output JSON when --json flag is specified

Test results:
- TestHashIDs_IdenticalContentDedup: Now passes (was failing)
- TestHashIDs_MultiCloneConverge: Passes without JSON parsing warnings
- All other tests: Pass with no regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-02 18:52:44 -08:00
parent edf1f71fa7
commit e5f1e4b971
15 changed files with 7 additions and 588 deletions
+1 -1
View File
@@ -272,6 +272,6 @@ func init() {
createCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") createCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')") createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')")
createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix") createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix")
createCmd.Flags().Bool("json", false, "Output JSON format") // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(createCmd) rootCmd.AddCommand(createCmd)
} }
-95
View File
@@ -1,5 +1,4 @@
package main package main
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
@@ -11,16 +10,13 @@ import (
"strings" "strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/daemon"
) )
var daemonsCmd = &cobra.Command{ var daemonsCmd = &cobra.Command{
Use: "daemons", Use: "daemons",
Short: "Manage multiple bd daemons", Short: "Manage multiple bd daemons",
Long: `Manage bd daemon processes across all repositories and worktrees. Long: `Manage bd daemon processes across all repositories and worktrees.
Subcommands: Subcommands:
list - Show all running daemons list - Show all running daemons
health - Check health of all daemons health - Check health of all daemons
@@ -29,7 +25,6 @@ Subcommands:
killall - Stop all running daemons killall - Stop all running daemons
restart - Restart a specific daemon (not yet implemented)`, restart - Restart a specific daemon (not yet implemented)`,
} }
var daemonsListCmd = &cobra.Command{ var daemonsListCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all running bd daemons", Short: "List all running bd daemons",
@@ -38,14 +33,12 @@ uptime, last activity, and exclusive lock status.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
searchRoots, _ := cmd.Flags().GetStringSlice("search") searchRoots, _ := cmd.Flags().GetStringSlice("search")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Discover daemons // Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots) daemons, err := daemon.DiscoverDaemons(searchRoots)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err) fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Auto-cleanup stale sockets (unless --no-cleanup flag is set) // Auto-cleanup stale sockets (unless --no-cleanup flag is set)
noCleanup, _ := cmd.Flags().GetBool("no-cleanup") noCleanup, _ := cmd.Flags().GetBool("no-cleanup")
if !noCleanup { if !noCleanup {
@@ -56,7 +49,6 @@ uptime, last activity, and exclusive lock status.`,
fmt.Fprintf(os.Stderr, "Cleaned up %d stale socket(s)\n", cleaned) fmt.Fprintf(os.Stderr, "Cleaned up %d stale socket(s)\n", cleaned)
} }
} }
// Filter to only alive daemons // Filter to only alive daemons
var aliveDaemons []daemon.DaemonInfo var aliveDaemons []daemon.DaemonInfo
for _, d := range daemons { for _, d := range daemons {
@@ -64,50 +56,40 @@ uptime, last activity, and exclusive lock status.`,
aliveDaemons = append(aliveDaemons, d) aliveDaemons = append(aliveDaemons, d)
} }
} }
if jsonOutput { if jsonOutput {
data, _ := json.MarshalIndent(aliveDaemons, "", " ") data, _ := json.MarshalIndent(aliveDaemons, "", " ")
fmt.Println(string(data)) fmt.Println(string(data))
return return
} }
// Human-readable table output // Human-readable table output
if len(aliveDaemons) == 0 { if len(aliveDaemons) == 0 {
fmt.Println("No running daemons found") fmt.Println("No running daemons found")
return return
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "WORKSPACE\tPID\tVERSION\tUPTIME\tLAST ACTIVITY\tLOCK") _, _ = fmt.Fprintln(w, "WORKSPACE\tPID\tVERSION\tUPTIME\tLAST ACTIVITY\tLOCK")
for _, d := range aliveDaemons { for _, d := range aliveDaemons {
workspace := d.WorkspacePath workspace := d.WorkspacePath
if workspace == "" { if workspace == "" {
workspace = "(unknown)" workspace = "(unknown)"
} }
uptime := formatDaemonDuration(d.UptimeSeconds) uptime := formatDaemonDuration(d.UptimeSeconds)
lastActivity := "(unknown)" lastActivity := "(unknown)"
if d.LastActivityTime != "" { if d.LastActivityTime != "" {
if t, err := time.Parse(time.RFC3339, d.LastActivityTime); err == nil { if t, err := time.Parse(time.RFC3339, d.LastActivityTime); err == nil {
lastActivity = formatDaemonRelativeTime(t) lastActivity = formatDaemonRelativeTime(t)
} }
} }
lock := "-" lock := "-"
if d.ExclusiveLockActive { if d.ExclusiveLockActive {
lock = fmt.Sprintf("🔒 %s", d.ExclusiveLockHolder) lock = fmt.Sprintf("🔒 %s", d.ExclusiveLockHolder)
} }
_, _ = fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\n", _, _ = fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\n",
workspace, d.PID, d.Version, uptime, lastActivity, lock) workspace, d.PID, d.Version, uptime, lastActivity, lock)
} }
_ = w.Flush() _ = w.Flush()
}, },
} }
func formatDaemonDuration(seconds float64) string { func formatDaemonDuration(seconds float64) string {
d := time.Duration(seconds * float64(time.Second)) d := time.Duration(seconds * float64(time.Second))
if d < time.Minute { if d < time.Minute {
@@ -119,7 +101,6 @@ func formatDaemonDuration(seconds float64) string {
} }
return fmt.Sprintf("%.1fd", d.Hours()/24) return fmt.Sprintf("%.1fd", d.Hours()/24)
} }
func formatDaemonRelativeTime(t time.Time) string { func formatDaemonRelativeTime(t time.Time) string {
d := time.Since(t) d := time.Since(t)
if d < time.Minute { if d < time.Minute {
@@ -131,7 +112,6 @@ func formatDaemonRelativeTime(t time.Time) string {
} }
return fmt.Sprintf("%.1fd ago", d.Hours()/24) return fmt.Sprintf("%.1fd ago", d.Hours()/24)
} }
var daemonsStopCmd = &cobra.Command{ var daemonsStopCmd = &cobra.Command{
Use: "stop <workspace-path|pid>", Use: "stop <workspace-path|pid>",
Short: "Stop a specific bd daemon", Short: "Stop a specific bd daemon",
@@ -141,14 +121,12 @@ Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
target := args[0] target := args[0]
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Discover all daemons // Discover all daemons
daemons, err := daemon.DiscoverDaemons(nil) daemons, err := daemon.DiscoverDaemons(nil)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err) fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Find matching daemon by workspace path or PID // Find matching daemon by workspace path or PID
var targetDaemon *daemon.DaemonInfo var targetDaemon *daemon.DaemonInfo
for _, d := range daemons { for _, d := range daemons {
@@ -157,7 +135,6 @@ Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`,
break break
} }
} }
if targetDaemon == nil { if targetDaemon == nil {
if jsonOutput { if jsonOutput {
outputJSON(map[string]string{"error": "daemon not found"}) outputJSON(map[string]string{"error": "daemon not found"})
@@ -166,7 +143,6 @@ Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`,
} }
os.Exit(1) os.Exit(1)
} }
// Stop the daemon // Stop the daemon
if err := daemon.StopDaemon(*targetDaemon); err != nil { if err := daemon.StopDaemon(*targetDaemon); err != nil {
if jsonOutput { if jsonOutput {
@@ -176,7 +152,6 @@ Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`,
} }
os.Exit(1) os.Exit(1)
} }
if jsonOutput { if jsonOutput {
outputJSON(map[string]interface{}{ outputJSON(map[string]interface{}{
"workspace": targetDaemon.WorkspacePath, "workspace": targetDaemon.WorkspacePath,
@@ -188,7 +163,6 @@ Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`,
} }
}, },
} }
var daemonsRestartCmd = &cobra.Command{ var daemonsRestartCmd = &cobra.Command{
Use: "restart <workspace-path|pid>", Use: "restart <workspace-path|pid>",
Short: "Restart a specific bd daemon", Short: "Restart a specific bd daemon",
@@ -199,14 +173,12 @@ Stops the daemon gracefully, then starts a new one.`,
target := args[0] target := args[0]
searchRoots, _ := cmd.Flags().GetStringSlice("search") searchRoots, _ := cmd.Flags().GetStringSlice("search")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Discover daemons // Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots) daemons, err := daemon.DiscoverDaemons(searchRoots)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err) fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Find the target daemon // Find the target daemon
var targetDaemon *daemon.DaemonInfo var targetDaemon *daemon.DaemonInfo
for _, d := range daemons { for _, d := range daemons {
@@ -215,7 +187,6 @@ Stops the daemon gracefully, then starts a new one.`,
break break
} }
} }
if targetDaemon == nil { if targetDaemon == nil {
if jsonOutput { if jsonOutput {
outputJSON(map[string]string{"error": "daemon not found"}) outputJSON(map[string]string{"error": "daemon not found"})
@@ -224,9 +195,7 @@ Stops the daemon gracefully, then starts a new one.`,
} }
os.Exit(1) os.Exit(1)
} }
workspace := targetDaemon.WorkspacePath workspace := targetDaemon.WorkspacePath
// Stop the daemon // Stop the daemon
if !jsonOutput { if !jsonOutput {
fmt.Printf("Stopping daemon for workspace: %s (PID %d)\n", workspace, targetDaemon.PID) fmt.Printf("Stopping daemon for workspace: %s (PID %d)\n", workspace, targetDaemon.PID)
@@ -239,15 +208,12 @@ Stops the daemon gracefully, then starts a new one.`,
} }
os.Exit(1) os.Exit(1)
} }
// Wait a moment for cleanup // Wait a moment for cleanup
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
// Start a new daemon by executing 'bd daemon' in the workspace directory // Start a new daemon by executing 'bd daemon' in the workspace directory
if !jsonOutput { if !jsonOutput {
fmt.Printf("Starting new daemon for workspace: %s\n", workspace) fmt.Printf("Starting new daemon for workspace: %s\n", workspace)
} }
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
if jsonOutput { if jsonOutput {
@@ -257,17 +223,14 @@ Stops the daemon gracefully, then starts a new one.`,
} }
os.Exit(1) os.Exit(1)
} }
// Check if workspace-local bd binary exists (preferred) // Check if workspace-local bd binary exists (preferred)
localBd := filepath.Join(workspace, "bd") localBd := filepath.Join(workspace, "bd")
_, localErr := os.Stat(localBd) _, localErr := os.Stat(localBd)
bdPath := exe bdPath := exe
if localErr == nil { if localErr == nil {
// Use local bd binary if it exists // Use local bd binary if it exists
bdPath = localBd bdPath = localBd
} }
// Use bd daemon command with proper working directory // Use bd daemon command with proper working directory
// The daemon will fork itself into the background // The daemon will fork itself into the background
daemonCmd := &exec.Cmd{ daemonCmd := &exec.Cmd{
@@ -276,7 +239,6 @@ Stops the daemon gracefully, then starts a new one.`,
Dir: workspace, Dir: workspace,
Env: os.Environ(), Env: os.Environ(),
} }
if err := daemonCmd.Start(); err != nil { if err := daemonCmd.Start(); err != nil {
if jsonOutput { if jsonOutput {
outputJSON(map[string]string{"error": fmt.Sprintf("failed to start daemon: %v", err)}) outputJSON(map[string]string{"error": fmt.Sprintf("failed to start daemon: %v", err)})
@@ -285,10 +247,8 @@ Stops the daemon gracefully, then starts a new one.`,
} }
os.Exit(1) os.Exit(1)
} }
// Don't wait for daemon to exit (it will fork and continue in background) // Don't wait for daemon to exit (it will fork and continue in background)
go func() { _ = daemonCmd.Wait() }() go func() { _ = daemonCmd.Wait() }()
if jsonOutput { if jsonOutput {
outputJSON(map[string]interface{}{ outputJSON(map[string]interface{}{
"workspace": workspace, "workspace": workspace,
@@ -299,7 +259,6 @@ Stops the daemon gracefully, then starts a new one.`,
} }
}, },
} }
var daemonsLogsCmd = &cobra.Command{ var daemonsLogsCmd = &cobra.Command{
Use: "logs <workspace-path|pid>", Use: "logs <workspace-path|pid>",
Short: "View logs for a specific bd daemon", Short: "View logs for a specific bd daemon",
@@ -311,7 +270,6 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
follow, _ := cmd.Flags().GetBool("follow") follow, _ := cmd.Flags().GetBool("follow")
lines, _ := cmd.Flags().GetInt("lines") lines, _ := cmd.Flags().GetInt("lines")
// Discover all daemons // Discover all daemons
daemons, err := daemon.DiscoverDaemons(nil) daemons, err := daemon.DiscoverDaemons(nil)
if err != nil { if err != nil {
@@ -322,7 +280,6 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
} }
os.Exit(1) os.Exit(1)
} }
// Find matching daemon by workspace path or PID // Find matching daemon by workspace path or PID
var targetDaemon *daemon.DaemonInfo var targetDaemon *daemon.DaemonInfo
for _, d := range daemons { for _, d := range daemons {
@@ -331,7 +288,6 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
break break
} }
} }
if targetDaemon == nil { if targetDaemon == nil {
if jsonOutput { if jsonOutput {
outputJSON(map[string]string{"error": "daemon not found"}) outputJSON(map[string]string{"error": "daemon not found"})
@@ -340,10 +296,8 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
} }
os.Exit(1) os.Exit(1)
} }
// Determine log file path // Determine log file path
logPath := filepath.Join(filepath.Dir(targetDaemon.SocketPath), "daemon.log") logPath := filepath.Join(filepath.Dir(targetDaemon.SocketPath), "daemon.log")
// Check if log file exists // Check if log file exists
if _, err := os.Stat(logPath); err != nil { if _, err := os.Stat(logPath); err != nil {
if jsonOutput { if jsonOutput {
@@ -353,7 +307,6 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
} }
os.Exit(1) os.Exit(1)
} }
if jsonOutput { if jsonOutput {
// JSON mode: read entire file // JSON mode: read entire file
// #nosec G304 - controlled path from daemon discovery // #nosec G304 - controlled path from daemon discovery
@@ -369,7 +322,6 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
}) })
return return
} }
// Human-readable mode // Human-readable mode
if follow { if follow {
tailFollow(logPath) tailFollow(logPath)
@@ -381,7 +333,6 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
} }
}, },
} }
func tailLines(filePath string, n int) error { func tailLines(filePath string, n int) error {
// #nosec G304 - controlled path from daemon discovery // #nosec G304 - controlled path from daemon discovery
file, err := os.Open(filePath) file, err := os.Open(filePath)
@@ -389,7 +340,6 @@ func tailLines(filePath string, n int) error {
return err return err
} }
defer file.Close() defer file.Close()
// Read all lines // Read all lines
var lines []string var lines []string
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
@@ -399,7 +349,6 @@ func tailLines(filePath string, n int) error {
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return err return err
} }
// Print last N lines // Print last N lines
start := 0 start := 0
if len(lines) > n { if len(lines) > n {
@@ -408,10 +357,8 @@ func tailLines(filePath string, n int) error {
for i := start; i < len(lines); i++ { for i := start; i < len(lines); i++ {
fmt.Println(lines[i]) fmt.Println(lines[i])
} }
return nil return nil
} }
func tailFollow(filePath string) { func tailFollow(filePath string) {
// #nosec G304 - controlled path from daemon discovery // #nosec G304 - controlled path from daemon discovery
file, err := os.Open(filePath) file, err := os.Open(filePath)
@@ -420,10 +367,8 @@ func tailFollow(filePath string) {
os.Exit(1) os.Exit(1)
} }
defer file.Close() defer file.Close()
// Seek to end // Seek to end
_, _ = file.Seek(0, io.SeekEnd) _, _ = file.Seek(0, io.SeekEnd)
reader := bufio.NewReader(file) reader := bufio.NewReader(file)
for { for {
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
@@ -439,7 +384,6 @@ func tailFollow(filePath string) {
fmt.Print(strings.TrimRight(line, "\n\r") + "\n") fmt.Print(strings.TrimRight(line, "\n\r") + "\n")
} }
} }
var daemonsKillallCmd = &cobra.Command{ var daemonsKillallCmd = &cobra.Command{
Use: "killall", Use: "killall",
Short: "Stop all running bd daemons", Short: "Stop all running bd daemons",
@@ -449,7 +393,6 @@ Uses escalating shutdown strategy: RPC (2s) → SIGTERM (3s) → SIGKILL (1s).`,
searchRoots, _ := cmd.Flags().GetStringSlice("search") searchRoots, _ := cmd.Flags().GetStringSlice("search")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
// Discover all daemons // Discover all daemons
daemons, err := daemon.DiscoverDaemons(searchRoots) daemons, err := daemon.DiscoverDaemons(searchRoots)
if err != nil { if err != nil {
@@ -460,7 +403,6 @@ Uses escalating shutdown strategy: RPC (2s) → SIGTERM (3s) → SIGKILL (1s).`,
} }
os.Exit(1) os.Exit(1)
} }
// Filter to alive daemons only // Filter to alive daemons only
var aliveDaemons []daemon.DaemonInfo var aliveDaemons []daemon.DaemonInfo
for _, d := range daemons { for _, d := range daemons {
@@ -468,7 +410,6 @@ Uses escalating shutdown strategy: RPC (2s) → SIGTERM (3s) → SIGKILL (1s).`,
aliveDaemons = append(aliveDaemons, d) aliveDaemons = append(aliveDaemons, d)
} }
} }
if len(aliveDaemons) == 0 { if len(aliveDaemons) == 0 {
if jsonOutput { if jsonOutput {
outputJSON(map[string]interface{}{ outputJSON(map[string]interface{}{
@@ -480,10 +421,8 @@ Uses escalating shutdown strategy: RPC (2s) → SIGTERM (3s) → SIGKILL (1s).`,
} }
return return
} }
// Kill all daemons // Kill all daemons
results := daemon.KillAllDaemons(aliveDaemons, force) results := daemon.KillAllDaemons(aliveDaemons, force)
if jsonOutput { if jsonOutput {
outputJSON(results) outputJSON(results)
} else { } else {
@@ -496,13 +435,11 @@ Uses escalating shutdown strategy: RPC (2s) → SIGTERM (3s) → SIGKILL (1s).`,
} }
} }
} }
if results.Failed > 0 { if results.Failed > 0 {
os.Exit(1) os.Exit(1)
} }
}, },
} }
var daemonsHealthCmd = &cobra.Command{ var daemonsHealthCmd = &cobra.Command{
Use: "health", Use: "health",
Short: "Check health of all bd daemons", Short: "Check health of all bd daemons",
@@ -511,14 +448,12 @@ stale sockets, version mismatches, and unresponsive daemons.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
searchRoots, _ := cmd.Flags().GetStringSlice("search") searchRoots, _ := cmd.Flags().GetStringSlice("search")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Discover daemons // Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots) daemons, err := daemon.DiscoverDaemons(searchRoots)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err) fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
os.Exit(1) os.Exit(1)
} }
type healthReport struct { type healthReport struct {
Workspace string `json:"workspace"` Workspace string `json:"workspace"`
SocketPath string `json:"socket_path"` SocketPath string `json:"socket_path"`
@@ -528,15 +463,12 @@ stale sockets, version mismatches, and unresponsive daemons.`,
Issue string `json:"issue,omitempty"` Issue string `json:"issue,omitempty"`
VersionMismatch bool `json:"version_mismatch,omitempty"` VersionMismatch bool `json:"version_mismatch,omitempty"`
} }
var reports []healthReport var reports []healthReport
healthyCount := 0 healthyCount := 0
staleCount := 0 staleCount := 0
mismatchCount := 0 mismatchCount := 0
unresponsiveCount := 0 unresponsiveCount := 0
currentVersion := Version currentVersion := Version
for _, d := range daemons { for _, d := range daemons {
report := healthReport{ report := healthReport{
Workspace: d.WorkspacePath, Workspace: d.WorkspacePath,
@@ -544,7 +476,6 @@ stale sockets, version mismatches, and unresponsive daemons.`,
PID: d.PID, PID: d.PID,
Version: d.Version, Version: d.Version,
} }
if !d.Alive { if !d.Alive {
report.Status = "stale" report.Status = "stale"
report.Issue = d.Error report.Issue = d.Error
@@ -558,10 +489,8 @@ stale sockets, version mismatches, and unresponsive daemons.`,
report.Status = "healthy" report.Status = "healthy"
healthyCount++ healthyCount++
} }
reports = append(reports, report) reports = append(reports, report)
} }
if jsonOutput { if jsonOutput {
output := map[string]interface{}{ output := map[string]interface{}{
"total": len(reports), "total": len(reports),
@@ -575,61 +504,49 @@ stale sockets, version mismatches, and unresponsive daemons.`,
fmt.Println(string(data)) fmt.Println(string(data))
return return
} }
// Human-readable output // Human-readable output
if len(reports) == 0 { if len(reports) == 0 {
fmt.Println("No daemons found") fmt.Println("No daemons found")
return return
} }
fmt.Printf("Health Check Summary:\n") fmt.Printf("Health Check Summary:\n")
fmt.Printf(" Total: %d\n", len(reports)) fmt.Printf(" Total: %d\n", len(reports))
fmt.Printf(" Healthy: %d\n", healthyCount) fmt.Printf(" Healthy: %d\n", healthyCount)
fmt.Printf(" Stale: %d\n", staleCount) fmt.Printf(" Stale: %d\n", staleCount)
fmt.Printf(" Mismatched: %d\n", mismatchCount) fmt.Printf(" Mismatched: %d\n", mismatchCount)
fmt.Printf(" Unresponsive: %d\n\n", unresponsiveCount) fmt.Printf(" Unresponsive: %d\n\n", unresponsiveCount)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "WORKSPACE\tPID\tVERSION\tSTATUS\tISSUE") _, _ = fmt.Fprintln(w, "WORKSPACE\tPID\tVERSION\tSTATUS\tISSUE")
for _, r := range reports { for _, r := range reports {
workspace := r.Workspace workspace := r.Workspace
if workspace == "" { if workspace == "" {
workspace = "(unknown)" workspace = "(unknown)"
} }
pidStr := "-" pidStr := "-"
if r.PID != 0 { if r.PID != 0 {
pidStr = fmt.Sprintf("%d", r.PID) pidStr = fmt.Sprintf("%d", r.PID)
} }
version := r.Version version := r.Version
if version == "" { if version == "" {
version = "-" version = "-"
} }
status := r.Status status := r.Status
issue := r.Issue issue := r.Issue
if issue == "" { if issue == "" {
issue = "-" issue = "-"
} }
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
workspace, pidStr, version, status, issue) workspace, pidStr, version, status, issue)
} }
_ = w.Flush() _ = w.Flush()
// Exit with error if there are any issues // Exit with error if there are any issues
if staleCount > 0 || mismatchCount > 0 || unresponsiveCount > 0 { if staleCount > 0 || mismatchCount > 0 || unresponsiveCount > 0 {
os.Exit(1) os.Exit(1)
} }
}, },
} }
func init() { func init() {
rootCmd.AddCommand(daemonsCmd) rootCmd.AddCommand(daemonsCmd)
// Add subcommands // Add subcommands
daemonsCmd.AddCommand(daemonsListCmd) daemonsCmd.AddCommand(daemonsListCmd)
daemonsCmd.AddCommand(daemonsHealthCmd) daemonsCmd.AddCommand(daemonsHealthCmd)
@@ -637,30 +554,18 @@ func init() {
daemonsCmd.AddCommand(daemonsLogsCmd) daemonsCmd.AddCommand(daemonsLogsCmd)
daemonsCmd.AddCommand(daemonsKillallCmd) daemonsCmd.AddCommand(daemonsKillallCmd)
daemonsCmd.AddCommand(daemonsRestartCmd) daemonsCmd.AddCommand(daemonsRestartCmd)
// Flags for list command // Flags for list command
daemonsListCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)") daemonsListCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
daemonsListCmd.Flags().Bool("json", false, "Output in JSON format")
daemonsListCmd.Flags().Bool("no-cleanup", false, "Skip auto-cleanup of stale sockets") daemonsListCmd.Flags().Bool("no-cleanup", false, "Skip auto-cleanup of stale sockets")
// Flags for health command // Flags for health command
daemonsHealthCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)") daemonsHealthCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
daemonsHealthCmd.Flags().Bool("json", false, "Output in JSON format")
// Flags for stop command // Flags for stop command
daemonsStopCmd.Flags().Bool("json", false, "Output in JSON format")
// Flags for logs command // Flags for logs command
daemonsLogsCmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)") daemonsLogsCmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
daemonsLogsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show from end of log") daemonsLogsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show from end of log")
daemonsLogsCmd.Flags().Bool("json", false, "Output in JSON format")
// Flags for killall command // Flags for killall command
daemonsKillallCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)") daemonsKillallCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
daemonsKillallCmd.Flags().Bool("json", false, "Output in JSON format")
daemonsKillallCmd.Flags().Bool("force", false, "Use SIGKILL immediately if graceful shutdown fails") daemonsKillallCmd.Flags().Bool("force", false, "Use SIGKILL immediately if graceful shutdown fails")
// Flags for restart command // Flags for restart command
daemonsRestartCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)") daemonsRestartCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
daemonsRestartCmd.Flags().Bool("json", false, "Output in JSON format")
} }
-91
View File
@@ -1,5 +1,4 @@
package main package main
import ( import (
"bufio" "bufio"
"context" "context"
@@ -8,44 +7,32 @@ import (
"os" "os"
"regexp" "regexp"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
var deleteCmd = &cobra.Command{ var deleteCmd = &cobra.Command{
Use: "delete <issue-id> [issue-id...]", Use: "delete <issue-id> [issue-id...]",
Short: "Delete one or more issues and clean up references", Short: "Delete one or more issues and clean up references",
Long: `Delete one or more issues and clean up all references to them. Long: `Delete one or more issues and clean up all references to them.
This command will: This command will:
1. Remove all dependency links (any type, both directions) involving the issues 1. Remove all dependency links (any type, both directions) involving the issues
2. Update text references to "[deleted:ID]" in directly connected issues 2. Update text references to "[deleted:ID]" in directly connected issues
3. Delete the issues from the database 3. Delete the issues from the database
This is a destructive operation that cannot be undone. Use with caution. This is a destructive operation that cannot be undone. Use with caution.
BATCH DELETION: BATCH DELETION:
Delete multiple issues at once: Delete multiple issues at once:
bd delete bd-1 bd-2 bd-3 --force bd delete bd-1 bd-2 bd-3 --force
Delete from file (one ID per line): Delete from file (one ID per line):
bd delete --from-file deletions.txt --force bd delete --from-file deletions.txt --force
Preview before deleting: Preview before deleting:
bd delete --from-file deletions.txt --dry-run bd delete --from-file deletions.txt --dry-run
DEPENDENCY HANDLING: DEPENDENCY HANDLING:
Default: Fails if any issue has dependents not in deletion set Default: Fails if any issue has dependents not in deletion set
bd delete bd-1 bd-2 bd delete bd-1 bd-2
Cascade: Recursively delete all dependents Cascade: Recursively delete all dependents
bd delete bd-1 --cascade --force bd delete bd-1 --cascade --force
Force: Delete and orphan dependents Force: Delete and orphan dependents
bd delete bd-1 --force`, bd delete bd-1 --force`,
Args: cobra.MinimumNArgs(0), Args: cobra.MinimumNArgs(0),
@@ -55,11 +42,9 @@ Force: Delete and orphan dependents
dryRun, _ := cmd.Flags().GetBool("dry-run") dryRun, _ := cmd.Flags().GetBool("dry-run")
cascade, _ := cmd.Flags().GetBool("cascade") cascade, _ := cmd.Flags().GetBool("cascade")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Collect issue IDs from args and/or file // Collect issue IDs from args and/or file
issueIDs := make([]string, 0, len(args)) issueIDs := make([]string, 0, len(args))
issueIDs = append(issueIDs, args...) issueIDs = append(issueIDs, args...)
if fromFile != "" { if fromFile != "" {
fileIDs, err := readIssueIDsFromFile(fromFile) fileIDs, err := readIssueIDsFromFile(fromFile)
if err != nil { if err != nil {
@@ -68,25 +53,20 @@ Force: Delete and orphan dependents
} }
issueIDs = append(issueIDs, fileIDs...) issueIDs = append(issueIDs, fileIDs...)
} }
if len(issueIDs) == 0 { if len(issueIDs) == 0 {
fmt.Fprintf(os.Stderr, "Error: no issue IDs provided\n") fmt.Fprintf(os.Stderr, "Error: no issue IDs provided\n")
_ = cmd.Usage() _ = cmd.Usage()
os.Exit(1) os.Exit(1)
} }
// Remove duplicates // Remove duplicates
issueIDs = uniqueStrings(issueIDs) issueIDs = uniqueStrings(issueIDs)
// Handle batch deletion // Handle batch deletion
if len(issueIDs) > 1 { if len(issueIDs) > 1 {
deleteBatch(cmd, issueIDs, force, dryRun, cascade, jsonOutput) deleteBatch(cmd, issueIDs, force, dryRun, cascade, jsonOutput)
return return
} }
// Single issue deletion (legacy behavior) // Single issue deletion (legacy behavior)
issueID := issueIDs[0] issueID := issueIDs[0]
// Ensure we have a direct store when daemon lacks delete support // Ensure we have a direct store when daemon lacks delete support
if daemonClient != nil { if daemonClient != nil {
if err := ensureDirectMode("daemon does not support delete command"); err != nil { if err := ensureDirectMode("daemon does not support delete command"); err != nil {
@@ -99,9 +79,7 @@ Force: Delete and orphan dependents
os.Exit(1) os.Exit(1)
} }
} }
ctx := context.Background() ctx := context.Background()
// Get the issue to be deleted // Get the issue to be deleted
issue, err := store.GetIssue(ctx, issueID) issue, err := store.GetIssue(ctx, issueID)
if err != nil { if err != nil {
@@ -112,10 +90,8 @@ Force: Delete and orphan dependents
fmt.Fprintf(os.Stderr, "Error: issue %s not found\n", issueID) fmt.Fprintf(os.Stderr, "Error: issue %s not found\n", issueID)
os.Exit(1) os.Exit(1)
} }
// Find all connected issues (dependencies in both directions) // Find all connected issues (dependencies in both directions)
connectedIssues := make(map[string]*types.Issue) connectedIssues := make(map[string]*types.Issue)
// Get dependencies (issues this one depends on) // Get dependencies (issues this one depends on)
deps, err := store.GetDependencies(ctx, issueID) deps, err := store.GetDependencies(ctx, issueID)
if err != nil { if err != nil {
@@ -125,7 +101,6 @@ Force: Delete and orphan dependents
for _, dep := range deps { for _, dep := range deps {
connectedIssues[dep.ID] = dep connectedIssues[dep.ID] = dep
} }
// Get dependents (issues that depend on this one) // Get dependents (issues that depend on this one)
dependents, err := store.GetDependents(ctx, issueID) dependents, err := store.GetDependents(ctx, issueID)
if err != nil { if err != nil {
@@ -135,29 +110,24 @@ Force: Delete and orphan dependents
for _, dependent := range dependents { for _, dependent := range dependents {
connectedIssues[dependent.ID] = dependent connectedIssues[dependent.ID] = dependent
} }
// Get dependency records (outgoing) to count how many we'll remove // Get dependency records (outgoing) to count how many we'll remove
depRecords, err := store.GetDependencyRecords(ctx, issueID) depRecords, err := store.GetDependencyRecords(ctx, issueID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error getting dependency records: %v\n", err) fmt.Fprintf(os.Stderr, "Error getting dependency records: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Build the regex pattern for matching issue IDs (handles hyphenated IDs properly) // Build the regex pattern for matching issue IDs (handles hyphenated IDs properly)
// Pattern: (^|non-word-char)(issueID)($|non-word-char) where word-char includes hyphen // Pattern: (^|non-word-char)(issueID)($|non-word-char) where word-char includes hyphen
idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(issueID) + `)($|[^A-Za-z0-9_-])` idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(issueID) + `)($|[^A-Za-z0-9_-])`
re := regexp.MustCompile(idPattern) re := regexp.MustCompile(idPattern)
replacementText := `$1[deleted:` + issueID + `]$3` replacementText := `$1[deleted:` + issueID + `]$3`
// Preview mode // Preview mode
if !force { if !force {
red := color.New(color.FgRed).SprintFunc() red := color.New(color.FgRed).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW")) fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW"))
fmt.Printf("\nIssue to delete:\n") fmt.Printf("\nIssue to delete:\n")
fmt.Printf(" %s: %s\n", issueID, issue.Title) fmt.Printf(" %s: %s\n", issueID, issue.Title)
totalDeps := len(depRecords) + len(dependents) totalDeps := len(depRecords) + len(dependents)
if totalDeps > 0 { if totalDeps > 0 {
fmt.Printf("\nDependency links to remove: %d\n", totalDeps) fmt.Printf("\nDependency links to remove: %d\n", totalDeps)
@@ -168,7 +138,6 @@ Force: Delete and orphan dependents
fmt.Printf(" %s → %s (inbound)\n", dep.ID, issueID) fmt.Printf(" %s → %s (inbound)\n", dep.ID, issueID)
} }
} }
if len(connectedIssues) > 0 { if len(connectedIssues) > 0 {
fmt.Printf("\nConnected issues where text references will be updated:\n") fmt.Printf("\nConnected issues where text references will be updated:\n")
issuesWithRefs := 0 issuesWithRefs := 0
@@ -178,7 +147,6 @@ Force: Delete and orphan dependents
(connIssue.Notes != "" && re.MatchString(connIssue.Notes)) || (connIssue.Notes != "" && re.MatchString(connIssue.Notes)) ||
(connIssue.Design != "" && re.MatchString(connIssue.Design)) || (connIssue.Design != "" && re.MatchString(connIssue.Design)) ||
(connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria)) (connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria))
if hasRefs { if hasRefs {
fmt.Printf(" %s: %s\n", id, connIssue.Title) fmt.Printf(" %s: %s\n", id, connIssue.Title)
issuesWithRefs++ issuesWithRefs++
@@ -188,43 +156,35 @@ Force: Delete and orphan dependents
fmt.Printf(" (none have text references)\n") fmt.Printf(" (none have text references)\n")
} }
} }
fmt.Printf("\n%s\n", yellow("This operation cannot be undone!")) fmt.Printf("\n%s\n", yellow("This operation cannot be undone!"))
fmt.Printf("To proceed, run: %s\n\n", yellow("bd delete "+issueID+" --force")) fmt.Printf("To proceed, run: %s\n\n", yellow("bd delete "+issueID+" --force"))
return return
} }
// Actually delete // Actually delete
// 1. Update text references in connected issues (all text fields) // 1. Update text references in connected issues (all text fields)
updatedIssueCount := 0 updatedIssueCount := 0
for id, connIssue := range connectedIssues { for id, connIssue := range connectedIssues {
updates := make(map[string]interface{}) updates := make(map[string]interface{})
// Replace in description // Replace in description
if re.MatchString(connIssue.Description) { if re.MatchString(connIssue.Description) {
newDesc := re.ReplaceAllString(connIssue.Description, replacementText) newDesc := re.ReplaceAllString(connIssue.Description, replacementText)
updates["description"] = newDesc updates["description"] = newDesc
} }
// Replace in notes // Replace in notes
if connIssue.Notes != "" && re.MatchString(connIssue.Notes) { if connIssue.Notes != "" && re.MatchString(connIssue.Notes) {
newNotes := re.ReplaceAllString(connIssue.Notes, replacementText) newNotes := re.ReplaceAllString(connIssue.Notes, replacementText)
updates["notes"] = newNotes updates["notes"] = newNotes
} }
// Replace in design // Replace in design
if connIssue.Design != "" && re.MatchString(connIssue.Design) { if connIssue.Design != "" && re.MatchString(connIssue.Design) {
newDesign := re.ReplaceAllString(connIssue.Design, replacementText) newDesign := re.ReplaceAllString(connIssue.Design, replacementText)
updates["design"] = newDesign updates["design"] = newDesign
} }
// Replace in acceptance_criteria // Replace in acceptance_criteria
if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) { if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) {
newAC := re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText) newAC := re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText)
updates["acceptance_criteria"] = newAC updates["acceptance_criteria"] = newAC
} }
if len(updates) > 0 { if len(updates) > 0 {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil { if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to update references in %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Warning: Failed to update references in %s: %v\n", id, err)
@@ -233,7 +193,6 @@ Force: Delete and orphan dependents
} }
} }
} }
// 2. Remove all dependency links (outgoing) // 2. Remove all dependency links (outgoing)
outgoingRemoved := 0 outgoingRemoved := 0
for _, dep := range depRecords { for _, dep := range depRecords {
@@ -244,7 +203,6 @@ Force: Delete and orphan dependents
outgoingRemoved++ outgoingRemoved++
} }
} }
// 3. Remove inbound dependency links (issues that depend on this one) // 3. Remove inbound dependency links (issues that depend on this one)
inboundRemoved := 0 inboundRemoved := 0
for _, dep := range dependents { for _, dep := range dependents {
@@ -255,21 +213,17 @@ Force: Delete and orphan dependents
inboundRemoved++ inboundRemoved++
} }
} }
// 4. Delete the issue itself from database // 4. Delete the issue itself from database
if err := deleteIssue(ctx, issueID); err != nil { if err := deleteIssue(ctx, issueID); err != nil {
fmt.Fprintf(os.Stderr, "Error deleting issue: %v\n", err) fmt.Fprintf(os.Stderr, "Error deleting issue: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// 5. Remove from JSONL (auto-flush can't see deletions) // 5. Remove from JSONL (auto-flush can't see deletions)
if err := removeIssueFromJSONL(issueID); err != nil { if err := removeIssueFromJSONL(issueID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to remove from JSONL: %v\n", err) fmt.Fprintf(os.Stderr, "Warning: Failed to remove from JSONL: %v\n", err)
} }
// Schedule auto-flush to update neighbors // Schedule auto-flush to update neighbors
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
totalDepsRemoved := outgoingRemoved + inboundRemoved totalDepsRemoved := outgoingRemoved + inboundRemoved
if jsonOutput { if jsonOutput {
outputJSON(map[string]interface{}{ outputJSON(map[string]interface{}{
@@ -285,7 +239,6 @@ Force: Delete and orphan dependents
} }
}, },
} }
// deleteIssue removes an issue from the database // deleteIssue removes an issue from the database
// Note: This is a direct database operation since Storage interface doesn't have Delete // Note: This is a direct database operation since Storage interface doesn't have Delete
func deleteIssue(ctx context.Context, issueID string) error { func deleteIssue(ctx context.Context, issueID string) error {
@@ -294,14 +247,11 @@ func deleteIssue(ctx context.Context, issueID string) error {
type deleter interface { type deleter interface {
DeleteIssue(ctx context.Context, id string) error DeleteIssue(ctx context.Context, id string) error
} }
if d, ok := store.(deleter); ok { if d, ok := store.(deleter); ok {
return d.DeleteIssue(ctx, issueID) return d.DeleteIssue(ctx, issueID)
} }
return fmt.Errorf("delete operation not supported by this storage backend") return fmt.Errorf("delete operation not supported by this storage backend")
} }
// removeIssueFromJSONL removes a deleted issue from the JSONL file // removeIssueFromJSONL removes a deleted issue from the JSONL file
// Auto-flush cannot see deletions because the dirty_issues row is deleted with the issue // Auto-flush cannot see deletions because the dirty_issues row is deleted with the issue
func removeIssueFromJSONL(issueID string) error { func removeIssueFromJSONL(issueID string) error {
@@ -309,7 +259,6 @@ func removeIssueFromJSONL(issueID string) error {
if path == "" { if path == "" {
return nil // No JSONL file yet return nil // No JSONL file yet
} }
// Read all issues except the deleted one // Read all issues except the deleted one
// #nosec G304 - controlled path from config // #nosec G304 - controlled path from config
f, err := os.Open(path) f, err := os.Open(path)
@@ -319,7 +268,6 @@ func removeIssueFromJSONL(issueID string) error {
} }
return fmt.Errorf("failed to open JSONL: %w", err) return fmt.Errorf("failed to open JSONL: %w", err)
} }
var issues []*types.Issue var issues []*types.Issue
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
@@ -340,11 +288,9 @@ func removeIssueFromJSONL(issueID string) error {
_ = f.Close() _ = f.Close()
return fmt.Errorf("failed to read JSONL: %w", err) return fmt.Errorf("failed to read JSONL: %w", err)
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
return fmt.Errorf("failed to close JSONL: %w", err) return fmt.Errorf("failed to close JSONL: %w", err)
} }
// Write to temp file atomically // Write to temp file atomically
temp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid()) temp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid())
// #nosec G304 - controlled path from config // #nosec G304 - controlled path from config
@@ -352,7 +298,6 @@ func removeIssueFromJSONL(issueID string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create temp file: %w", err) return fmt.Errorf("failed to create temp file: %w", err)
} }
enc := json.NewEncoder(out) enc := json.NewEncoder(out)
for _, iss := range issues { for _, iss := range issues {
if err := enc.Encode(iss); err != nil { if err := enc.Encode(iss); err != nil {
@@ -361,21 +306,17 @@ func removeIssueFromJSONL(issueID string) error {
return fmt.Errorf("failed to write issue: %w", err) return fmt.Errorf("failed to write issue: %w", err)
} }
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
_ = os.Remove(temp) _ = os.Remove(temp)
return fmt.Errorf("failed to close temp file: %w", err) return fmt.Errorf("failed to close temp file: %w", err)
} }
// Atomic rename // Atomic rename
if err := os.Rename(temp, path); err != nil { if err := os.Rename(temp, path); err != nil {
_ = os.Remove(temp) _ = os.Remove(temp)
return fmt.Errorf("failed to rename temp file: %w", err) return fmt.Errorf("failed to rename temp file: %w", err)
} }
return nil return nil
} }
// deleteBatch handles deletion of multiple issues // deleteBatch handles deletion of multiple issues
//nolint:unparam // cmd parameter required for potential future use //nolint:unparam // cmd parameter required for potential future use
func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, cascade bool, jsonOutput bool) { func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, cascade bool, jsonOutput bool) {
@@ -391,16 +332,13 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
os.Exit(1) os.Exit(1)
} }
} }
ctx := context.Background() ctx := context.Background()
// Type assert to SQLite storage // Type assert to SQLite storage
d, ok := store.(*sqlite.SQLiteStorage) d, ok := store.(*sqlite.SQLiteStorage)
if !ok { if !ok {
fmt.Fprintf(os.Stderr, "Error: batch delete not supported by this storage backend\n") fmt.Fprintf(os.Stderr, "Error: batch delete not supported by this storage backend\n")
os.Exit(1) os.Exit(1)
} }
// Verify all issues exist // Verify all issues exist
issues := make(map[string]*types.Issue) issues := make(map[string]*types.Issue)
notFound := []string{} notFound := []string{}
@@ -416,12 +354,10 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
issues[id] = issue issues[id] = issue
} }
} }
if len(notFound) > 0 { if len(notFound) > 0 {
fmt.Fprintf(os.Stderr, "Error: issues not found: %s\n", strings.Join(notFound, ", ")) fmt.Fprintf(os.Stderr, "Error: issues not found: %s\n", strings.Join(notFound, ", "))
os.Exit(1) os.Exit(1)
} }
// Dry-run or preview mode // Dry-run or preview mode
if dryRun || !force { if dryRun || !force {
result, err := d.DeleteIssues(ctx, issueIDs, cascade, false, true) result, err := d.DeleteIssues(ctx, issueIDs, cascade, false, true)
@@ -430,7 +366,6 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
showDeletionPreview(issueIDs, issues, cascade, err) showDeletionPreview(issueIDs, issues, cascade, err)
os.Exit(1) os.Exit(1)
} }
showDeletionPreview(issueIDs, issues, cascade, nil) showDeletionPreview(issueIDs, issues, cascade, nil)
fmt.Printf("\nWould delete: %d issues\n", result.DeletedCount) fmt.Printf("\nWould delete: %d issues\n", result.DeletedCount)
fmt.Printf("Would remove: %d dependencies, %d labels, %d events\n", fmt.Printf("Would remove: %d dependencies, %d labels, %d events\n",
@@ -438,7 +373,6 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
if len(result.OrphanedIssues) > 0 { if len(result.OrphanedIssues) > 0 {
fmt.Printf("Would orphan: %d issues\n", len(result.OrphanedIssues)) fmt.Printf("Would orphan: %d issues\n", len(result.OrphanedIssues))
} }
if dryRun { if dryRun {
fmt.Printf("\n(Dry-run mode - no changes made)\n") fmt.Printf("\n(Dry-run mode - no changes made)\n")
} else { } else {
@@ -454,14 +388,12 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
} }
return return
} }
// Pre-collect connected issues before deletion (so we can update their text references) // Pre-collect connected issues before deletion (so we can update their text references)
connectedIssues := make(map[string]*types.Issue) connectedIssues := make(map[string]*types.Issue)
idSet := make(map[string]bool) idSet := make(map[string]bool)
for _, id := range issueIDs { for _, id := range issueIDs {
idSet[id] = true idSet[id] = true
} }
for _, id := range issueIDs { for _, id := range issueIDs {
// Get dependencies (issues this one depends on) // Get dependencies (issues this one depends on)
deps, err := store.GetDependencies(ctx, id) deps, err := store.GetDependencies(ctx, id)
@@ -472,7 +404,6 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
} }
} }
} }
// Get dependents (issues that depend on this one) // Get dependents (issues that depend on this one)
dependents, err := store.GetDependents(ctx, id) dependents, err := store.GetDependents(ctx, id)
if err == nil { if err == nil {
@@ -483,27 +414,22 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
} }
} }
} }
// Actually delete // Actually delete
result, err := d.DeleteIssues(ctx, issueIDs, cascade, force, false) result, err := d.DeleteIssues(ctx, issueIDs, cascade, force, false)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Update text references in connected issues (using pre-collected issues) // Update text references in connected issues (using pre-collected issues)
updatedCount := updateTextReferencesInIssues(ctx, issueIDs, connectedIssues) updatedCount := updateTextReferencesInIssues(ctx, issueIDs, connectedIssues)
// Remove from JSONL // Remove from JSONL
for _, id := range issueIDs { for _, id := range issueIDs {
if err := removeIssueFromJSONL(id); err != nil { if err := removeIssueFromJSONL(id); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to remove %s from JSONL: %v\n", id, err) fmt.Fprintf(os.Stderr, "Warning: Failed to remove %s from JSONL: %v\n", id, err)
} }
} }
// Schedule auto-flush // Schedule auto-flush
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
// Output results // Output results
if jsonOutput { if jsonOutput {
outputJSON(map[string]interface{}{ outputJSON(map[string]interface{}{
@@ -529,12 +455,10 @@ func deleteBatch(_ *cobra.Command, issueIDs []string, force bool, dryRun bool, c
} }
} }
} }
// showDeletionPreview shows what would be deleted // showDeletionPreview shows what would be deleted
func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, cascade bool, depError error) { func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, cascade bool, depError error) {
red := color.New(color.FgRed).SprintFunc() red := color.New(color.FgRed).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW")) fmt.Printf("\n%s\n", red("⚠️ DELETE PREVIEW"))
fmt.Printf("\nIssues to delete (%d):\n", len(issueIDs)) fmt.Printf("\nIssues to delete (%d):\n", len(issueIDs))
for _, id := range issueIDs { for _, id := range issueIDs {
@@ -542,30 +466,24 @@ func showDeletionPreview(issueIDs []string, issues map[string]*types.Issue, casc
fmt.Printf(" %s: %s\n", id, issue.Title) fmt.Printf(" %s: %s\n", id, issue.Title)
} }
} }
if cascade { if cascade {
fmt.Printf("\n%s Cascade mode enabled - will also delete all dependent issues\n", yellow("⚠")) fmt.Printf("\n%s Cascade mode enabled - will also delete all dependent issues\n", yellow("⚠"))
} }
if depError != nil { if depError != nil {
fmt.Printf("\n%s\n", red(depError.Error())) fmt.Printf("\n%s\n", red(depError.Error()))
} }
} }
// updateTextReferencesInIssues updates text references to deleted issues in pre-collected connected issues // updateTextReferencesInIssues updates text references to deleted issues in pre-collected connected issues
func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, connectedIssues map[string]*types.Issue) int { func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, connectedIssues map[string]*types.Issue) int {
updatedCount := 0 updatedCount := 0
// For each deleted issue, update references in all connected issues // For each deleted issue, update references in all connected issues
for _, id := range deletedIDs { for _, id := range deletedIDs {
// Build regex pattern // Build regex pattern
idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(id) + `)($|[^A-Za-z0-9_-])` idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(id) + `)($|[^A-Za-z0-9_-])`
re := regexp.MustCompile(idPattern) re := regexp.MustCompile(idPattern)
replacementText := `$1[deleted:` + id + `]$3` replacementText := `$1[deleted:` + id + `]$3`
for connID, connIssue := range connectedIssues { for connID, connIssue := range connectedIssues {
updates := make(map[string]interface{}) updates := make(map[string]interface{})
if re.MatchString(connIssue.Description) { if re.MatchString(connIssue.Description) {
updates["description"] = re.ReplaceAllString(connIssue.Description, replacementText) updates["description"] = re.ReplaceAllString(connIssue.Description, replacementText)
} }
@@ -578,7 +496,6 @@ func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, conn
if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) { if connIssue.AcceptanceCriteria != "" && re.MatchString(connIssue.AcceptanceCriteria) {
updates["acceptance_criteria"] = re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText) updates["acceptance_criteria"] = re.ReplaceAllString(connIssue.AcceptanceCriteria, replacementText)
} }
if len(updates) > 0 { if len(updates) > 0 {
if err := store.UpdateIssue(ctx, connID, updates, actor); err == nil { if err := store.UpdateIssue(ctx, connID, updates, actor); err == nil {
updatedCount++ updatedCount++
@@ -599,10 +516,8 @@ func updateTextReferencesInIssues(ctx context.Context, deletedIDs []string, conn
} }
} }
} }
return updatedCount return updatedCount
} }
// readIssueIDsFromFile reads issue IDs from a file (one per line) // readIssueIDsFromFile reads issue IDs from a file (one per line)
func readIssueIDsFromFile(filename string) ([]string, error) { func readIssueIDsFromFile(filename string) ([]string, error) {
// #nosec G304 - user-provided file path is intentional // #nosec G304 - user-provided file path is intentional
@@ -611,7 +526,6 @@ func readIssueIDsFromFile(filename string) ([]string, error) {
return nil, err return nil, err
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
var ids []string var ids []string
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
@@ -622,14 +536,11 @@ func readIssueIDsFromFile(filename string) ([]string, error) {
} }
ids = append(ids, line) ids = append(ids, line)
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, err return nil, err
} }
return ids, nil return ids, nil
} }
// uniqueStrings removes duplicates from a slice of strings // uniqueStrings removes duplicates from a slice of strings
func uniqueStrings(slice []string) []string { func uniqueStrings(slice []string) []string {
seen := make(map[string]bool) seen := make(map[string]bool)
@@ -642,12 +553,10 @@ func uniqueStrings(slice []string) []string {
} }
return result return result
} }
func init() { func init() {
deleteCmd.Flags().BoolP("force", "f", false, "Actually delete (without this flag, shows preview)") deleteCmd.Flags().BoolP("force", "f", false, "Actually delete (without this flag, shows preview)")
deleteCmd.Flags().String("from-file", "", "Read issue IDs from file (one per line)") deleteCmd.Flags().String("from-file", "", "Read issue IDs from file (one per line)")
deleteCmd.Flags().Bool("dry-run", false, "Preview what would be deleted without making changes") deleteCmd.Flags().Bool("dry-run", false, "Preview what would be deleted without making changes")
deleteCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues") deleteCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues")
deleteCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(deleteCmd) rootCmd.AddCommand(deleteCmd)
} }
+4 -4
View File
@@ -444,17 +444,17 @@ func getStatusEmoji(status types.Status) string {
func init() { func init() {
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)") depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)")
depAddCmd.Flags().Bool("json", false, "Output JSON format") // Note: --json flag is defined as a persistent flag in main.go, not here
depRemoveCmd.Flags().Bool("json", false, "Output JSON format") // Note: --json flag is defined as a persistent flag in main.go, not here
depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)") depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)")
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)") depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this)") depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this)")
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart") depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
depTreeCmd.Flags().Bool("json", false, "Output JSON format") // Note: --json flag is defined as a persistent flag in main.go, not here
depCyclesCmd.Flags().Bool("json", false, "Output JSON format") // Note: --json flag is defined as a persistent flag in main.go, not here
depCmd.AddCommand(depAddCmd) depCmd.AddCommand(depAddCmd)
depCmd.AddCommand(depRemoveCmd) depCmd.AddCommand(depRemoveCmd)
-47
View File
@@ -1,29 +1,23 @@
package main package main
import ( import (
"context" "context"
"fmt" "fmt"
"os" "os"
"regexp" "regexp"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
var duplicatesCmd = &cobra.Command{ var duplicatesCmd = &cobra.Command{
Use: "duplicates", Use: "duplicates",
Short: "Find and optionally merge duplicate issues", Short: "Find and optionally merge duplicate issues",
Long: `Find issues with identical content (title, description, design, acceptance criteria). Long: `Find issues with identical content (title, description, design, acceptance criteria).
Groups issues by content hash and reports duplicates with suggested merge targets. Groups issues by content hash and reports duplicates with suggested merge targets.
The merge target is chosen by: The merge target is chosen by:
1. Reference count (most referenced issue wins) 1. Reference count (most referenced issue wins)
2. Lexicographically smallest ID if reference counts are equal 2. Lexicographically smallest ID if reference counts are equal
Only groups issues with matching status (open with open, closed with closed). Only groups issues with matching status (open with open, closed with closed).
Example: Example:
bd duplicates # Show all duplicate groups bd duplicates # Show all duplicate groups
bd duplicates --auto-merge # Automatically merge all duplicates bd duplicates --auto-merge # Automatically merge all duplicates
@@ -35,20 +29,16 @@ Example:
fmt.Fprintf(os.Stderr, "Use: bd --no-daemon duplicates\n") fmt.Fprintf(os.Stderr, "Use: bd --no-daemon duplicates\n")
os.Exit(1) os.Exit(1)
} }
autoMerge, _ := cmd.Flags().GetBool("auto-merge") autoMerge, _ := cmd.Flags().GetBool("auto-merge")
dryRun, _ := cmd.Flags().GetBool("dry-run") dryRun, _ := cmd.Flags().GetBool("dry-run")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
ctx := context.Background() ctx := context.Background()
// Get all issues // Get all issues
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err) fmt.Fprintf(os.Stderr, "Error fetching issues: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Filter out closed issues - they're done, no point detecting duplicates // Filter out closed issues - they're done, no point detecting duplicates
openIssues := make([]*types.Issue, 0, len(allIssues)) openIssues := make([]*types.Issue, 0, len(allIssues))
for _, issue := range allIssues { for _, issue := range allIssues {
@@ -56,10 +46,8 @@ Example:
openIssues = append(openIssues, issue) openIssues = append(openIssues, issue)
} }
} }
// Find duplicates (only among open issues) // Find duplicates (only among open issues)
duplicateGroups := findDuplicateGroups(openIssues) duplicateGroups := findDuplicateGroups(openIssues)
if len(duplicateGroups) == 0 { if len(duplicateGroups) == 0 {
if !jsonOutput { if !jsonOutput {
fmt.Println("No duplicates found!") fmt.Println("No duplicates found!")
@@ -71,14 +59,11 @@ Example:
} }
return return
} }
// Count references for each issue // Count references for each issue
refCounts := countReferences(allIssues) refCounts := countReferences(allIssues)
// Prepare output // Prepare output
var mergeCommands []string var mergeCommands []string
var mergeResults []map[string]interface{} var mergeResults []map[string]interface{}
for _, group := range duplicateGroups { for _, group := range duplicateGroups {
target := chooseMergeTarget(group, refCounts) target := chooseMergeTarget(group, refCounts)
sources := make([]string, 0, len(group)-1) sources := make([]string, 0, len(group)-1)
@@ -87,7 +72,6 @@ Example:
sources = append(sources, issue.ID) sources = append(sources, issue.ID)
} }
} }
if autoMerge || dryRun { if autoMerge || dryRun {
// Perform merge (unless dry-run) // Perform merge (unless dry-run)
if !dryRun { if !dryRun {
@@ -96,7 +80,6 @@ Example:
fmt.Fprintf(os.Stderr, "Error merging %s into %s: %v\n", strings.Join(sources, ", "), target.ID, err) fmt.Fprintf(os.Stderr, "Error merging %s into %s: %v\n", strings.Join(sources, ", "), target.ID, err)
continue continue
} }
if jsonOutput { if jsonOutput {
mergeResults = append(mergeResults, map[string]interface{}{ mergeResults = append(mergeResults, map[string]interface{}{
"target_id": target.ID, "target_id": target.ID,
@@ -109,7 +92,6 @@ Example:
}) })
} }
} }
cmd := fmt.Sprintf("bd merge %s --into %s", strings.Join(sources, " "), target.ID) cmd := fmt.Sprintf("bd merge %s --into %s", strings.Join(sources, " "), target.ID)
mergeCommands = append(mergeCommands, cmd) mergeCommands = append(mergeCommands, cmd)
} else { } else {
@@ -117,12 +99,10 @@ Example:
mergeCommands = append(mergeCommands, cmd) mergeCommands = append(mergeCommands, cmd)
} }
} }
// Mark dirty if we performed merges // Mark dirty if we performed merges
if autoMerge && !dryRun && len(mergeCommands) > 0 { if autoMerge && !dryRun && len(mergeCommands) > 0 {
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
// Output results // Output results
if jsonOutput { if jsonOutput {
output := map[string]interface{}{ output := map[string]interface{}{
@@ -140,13 +120,10 @@ Example:
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Found %d duplicate group(s):\n\n", yellow("🔍"), len(duplicateGroups)) fmt.Printf("%s Found %d duplicate group(s):\n\n", yellow("🔍"), len(duplicateGroups))
for i, group := range duplicateGroups { for i, group := range duplicateGroups {
target := chooseMergeTarget(group, refCounts) target := chooseMergeTarget(group, refCounts)
fmt.Printf("%s Group %d: %s\n", cyan("━━"), i+1, group[0].Title) fmt.Printf("%s Group %d: %s\n", cyan("━━"), i+1, group[0].Title)
for _, issue := range group { for _, issue := range group {
refs := refCounts[issue.ID] refs := refCounts[issue.ID]
marker := " " marker := " "
@@ -156,7 +133,6 @@ Example:
fmt.Printf("%s%s (%s, P%d, %d references)\n", fmt.Printf("%s%s (%s, P%d, %d references)\n",
marker, issue.ID, issue.Status, issue.Priority, refs) marker, issue.ID, issue.Status, issue.Priority, refs)
} }
sources := make([]string, 0, len(group)-1) sources := make([]string, 0, len(group)-1)
for _, issue := range group { for _, issue := range group {
if issue.ID != target.ID { if issue.ID != target.ID {
@@ -166,7 +142,6 @@ Example:
fmt.Printf(" %s bd merge %s --into %s\n\n", fmt.Printf(" %s bd merge %s --into %s\n\n",
cyan("Suggested:"), strings.Join(sources, " "), target.ID) cyan("Suggested:"), strings.Join(sources, " "), target.ID)
} }
if autoMerge { if autoMerge {
if dryRun { if dryRun {
fmt.Printf("%s Dry run - would execute %d merge(s)\n", yellow("⚠"), len(mergeCommands)) fmt.Printf("%s Dry run - would execute %d merge(s)\n", yellow("⚠"), len(mergeCommands))
@@ -179,14 +154,11 @@ Example:
} }
}, },
} }
func init() { func init() {
duplicatesCmd.Flags().Bool("auto-merge", false, "Automatically merge all duplicates") duplicatesCmd.Flags().Bool("auto-merge", false, "Automatically merge all duplicates")
duplicatesCmd.Flags().Bool("dry-run", false, "Show what would be merged without making changes") duplicatesCmd.Flags().Bool("dry-run", false, "Show what would be merged without making changes")
duplicatesCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(duplicatesCmd) rootCmd.AddCommand(duplicatesCmd)
} }
// contentKey represents the fields we use to identify duplicate issues // contentKey represents the fields we use to identify duplicate issues
type contentKey struct { type contentKey struct {
title string title string
@@ -195,11 +167,9 @@ type contentKey struct {
acceptanceCriteria string acceptanceCriteria string
status string // Only group issues with same status status string // Only group issues with same status
} }
// findDuplicateGroups groups issues by content hash // findDuplicateGroups groups issues by content hash
func findDuplicateGroups(issues []*types.Issue) [][]*types.Issue { func findDuplicateGroups(issues []*types.Issue) [][]*types.Issue {
groups := make(map[contentKey][]*types.Issue) groups := make(map[contentKey][]*types.Issue)
for _, issue := range issues { for _, issue := range issues {
key := contentKey{ key := contentKey{
title: issue.Title, title: issue.Title,
@@ -208,10 +178,8 @@ func findDuplicateGroups(issues []*types.Issue) [][]*types.Issue {
acceptanceCriteria: issue.AcceptanceCriteria, acceptanceCriteria: issue.AcceptanceCriteria,
status: string(issue.Status), status: string(issue.Status),
} }
groups[key] = append(groups[key], issue) groups[key] = append(groups[key], issue)
} }
// Filter to only groups with duplicates // Filter to only groups with duplicates
var duplicates [][]*types.Issue var duplicates [][]*types.Issue
for _, group := range groups { for _, group := range groups {
@@ -219,15 +187,12 @@ func findDuplicateGroups(issues []*types.Issue) [][]*types.Issue {
duplicates = append(duplicates, group) duplicates = append(duplicates, group)
} }
} }
return duplicates return duplicates
} }
// countReferences counts how many times each issue is referenced in text fields // countReferences counts how many times each issue is referenced in text fields
func countReferences(issues []*types.Issue) map[string]int { func countReferences(issues []*types.Issue) map[string]int {
counts := make(map[string]int) counts := make(map[string]int)
idPattern := regexp.MustCompile(`\b[a-zA-Z][-a-zA-Z0-9]*-\d+\b`) idPattern := regexp.MustCompile(`\b[a-zA-Z][-a-zA-Z0-9]*-\d+\b`)
for _, issue := range issues { for _, issue := range issues {
// Search in all text fields // Search in all text fields
textFields := []string{ textFields := []string{
@@ -236,7 +201,6 @@ func countReferences(issues []*types.Issue) map[string]int {
issue.AcceptanceCriteria, issue.AcceptanceCriteria,
issue.Notes, issue.Notes,
} }
for _, text := range textFields { for _, text := range textFields {
matches := idPattern.FindAllString(text, -1) matches := idPattern.FindAllString(text, -1)
for _, match := range matches { for _, match := range matches {
@@ -244,20 +208,16 @@ func countReferences(issues []*types.Issue) map[string]int {
} }
} }
} }
return counts return counts
} }
// chooseMergeTarget selects the best issue to merge into // chooseMergeTarget selects the best issue to merge into
// Priority: highest reference count, then lexicographically smallest ID // Priority: highest reference count, then lexicographically smallest ID
func chooseMergeTarget(group []*types.Issue, refCounts map[string]int) *types.Issue { func chooseMergeTarget(group []*types.Issue, refCounts map[string]int) *types.Issue {
if len(group) == 0 { if len(group) == 0 {
return nil return nil
} }
target := group[0] target := group[0]
targetRefs := refCounts[target.ID] targetRefs := refCounts[target.ID]
for _, issue := range group[1:] { for _, issue := range group[1:] {
issueRefs := refCounts[issue.ID] issueRefs := refCounts[issue.ID]
if issueRefs > targetRefs || (issueRefs == targetRefs && issue.ID < target.ID) { if issueRefs > targetRefs || (issueRefs == targetRefs && issue.ID < target.ID) {
@@ -265,18 +225,14 @@ func chooseMergeTarget(group []*types.Issue, refCounts map[string]int) *types.Is
targetRefs = issueRefs targetRefs = issueRefs
} }
} }
return target return target
} }
// formatDuplicateGroupsJSON formats duplicate groups for JSON output // formatDuplicateGroupsJSON formats duplicate groups for JSON output
func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int) []map[string]interface{} { func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int) []map[string]interface{} {
var result []map[string]interface{} var result []map[string]interface{}
for _, group := range groups { for _, group := range groups {
target := chooseMergeTarget(group, refCounts) target := chooseMergeTarget(group, refCounts)
issues := make([]map[string]interface{}, len(group)) issues := make([]map[string]interface{}, len(group))
for i, issue := range group { for i, issue := range group {
issues[i] = map[string]interface{}{ issues[i] = map[string]interface{}{
"id": issue.ID, "id": issue.ID,
@@ -287,14 +243,12 @@ func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int
"is_merge_target": issue.ID == target.ID, "is_merge_target": issue.ID == target.ID,
} }
} }
sources := make([]string, 0, len(group)-1) sources := make([]string, 0, len(group)-1)
for _, issue := range group { for _, issue := range group {
if issue.ID != target.ID { if issue.ID != target.ID {
sources = append(sources, issue.ID) sources = append(sources, issue.ID)
} }
} }
result = append(result, map[string]interface{}{ result = append(result, map[string]interface{}{
"title": group[0].Title, "title": group[0].Title,
"issues": issues, "issues": issues,
@@ -303,6 +257,5 @@ func formatDuplicateGroupsJSON(groups [][]*types.Issue, refCounts map[string]int
"suggested_merge_cmd": fmt.Sprintf("bd merge %s --into %s", strings.Join(sources, " "), target.ID), "suggested_merge_cmd": fmt.Sprintf("bd merge %s --into %s", strings.Join(sources, " "), target.ID),
}) })
} }
return result return result
} }
-26
View File
@@ -1,32 +1,26 @@
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"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/types" "github.com/steveyegge/beads/internal/types"
) )
var epicCmd = &cobra.Command{ var epicCmd = &cobra.Command{
Use: "epic", Use: "epic",
Short: "Epic management commands", Short: "Epic management commands",
} }
var epicStatusCmd = &cobra.Command{ var epicStatusCmd = &cobra.Command{
Use: "status", Use: "status",
Short: "Show epic completion status", Short: "Show epic completion status",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
eligibleOnly, _ := cmd.Flags().GetBool("eligible-only") eligibleOnly, _ := cmd.Flags().GetBool("eligible-only")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
var epics []*types.EpicStatus var epics []*types.EpicStatus
var err error var err error
if daemonClient != nil { if daemonClient != nil {
resp, err := daemonClient.EpicStatus(&rpc.EpicStatusArgs{ resp, err := daemonClient.EpicStatus(&rpc.EpicStatusArgs{
EligibleOnly: eligibleOnly, EligibleOnly: eligibleOnly,
@@ -50,7 +44,6 @@ var epicStatusCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error getting epic status: %v\n", err) fmt.Fprintf(os.Stderr, "Error getting epic status: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if eligibleOnly { if eligibleOnly {
filtered := []*types.EpicStatus{} filtered := []*types.EpicStatus{}
for _, epic := range epics { for _, epic := range epics {
@@ -61,7 +54,6 @@ var epicStatusCmd = &cobra.Command{
epics = filtered epics = filtered
} }
} }
if jsonOutput { if jsonOutput {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ") enc.SetIndent("", " ")
@@ -71,25 +63,21 @@ var epicStatusCmd = &cobra.Command{
} }
return return
} }
// Human-readable output // Human-readable output
if len(epics) == 0 { if len(epics) == 0 {
fmt.Println("No open epics found") fmt.Println("No open epics found")
return return
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
bold := color.New(color.Bold).SprintFunc() bold := color.New(color.Bold).SprintFunc()
for _, epicStatus := range epics { for _, epicStatus := range epics {
epic := epicStatus.Epic epic := epicStatus.Epic
percentage := 0 percentage := 0
if epicStatus.TotalChildren > 0 { if epicStatus.TotalChildren > 0 {
percentage = (epicStatus.ClosedChildren * 100) / epicStatus.TotalChildren percentage = (epicStatus.ClosedChildren * 100) / epicStatus.TotalChildren
} }
statusIcon := "" statusIcon := ""
if epicStatus.EligibleForClose { if epicStatus.EligibleForClose {
statusIcon = green("✓") statusIcon = green("✓")
@@ -98,7 +86,6 @@ var epicStatusCmd = &cobra.Command{
} else { } else {
statusIcon = "○" statusIcon = "○"
} }
fmt.Printf("%s %s %s\n", statusIcon, cyan(epic.ID), bold(epic.Title)) fmt.Printf("%s %s %s\n", statusIcon, cyan(epic.ID), bold(epic.Title))
fmt.Printf(" Progress: %d/%d children closed (%d%%)\n", fmt.Printf(" Progress: %d/%d children closed (%d%%)\n",
epicStatus.ClosedChildren, epicStatus.TotalChildren, percentage) epicStatus.ClosedChildren, epicStatus.TotalChildren, percentage)
@@ -109,16 +96,13 @@ var epicStatusCmd = &cobra.Command{
} }
}, },
} }
var closeEligibleEpicsCmd = &cobra.Command{ var closeEligibleEpicsCmd = &cobra.Command{
Use: "close-eligible", Use: "close-eligible",
Short: "Close epics where all children are complete", Short: "Close epics where all children are complete",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
dryRun, _ := cmd.Flags().GetBool("dry-run") dryRun, _ := cmd.Flags().GetBool("dry-run")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
var eligibleEpics []*types.EpicStatus var eligibleEpics []*types.EpicStatus
if daemonClient != nil { if daemonClient != nil {
resp, err := daemonClient.EpicStatus(&rpc.EpicStatusArgs{ resp, err := daemonClient.EpicStatus(&rpc.EpicStatusArgs{
EligibleOnly: true, EligibleOnly: true,
@@ -148,7 +132,6 @@ var closeEligibleEpicsCmd = &cobra.Command{
} }
} }
} }
if len(eligibleEpics) == 0 { if len(eligibleEpics) == 0 {
if !jsonOutput { if !jsonOutput {
fmt.Println("No epics eligible for closure") fmt.Println("No epics eligible for closure")
@@ -157,7 +140,6 @@ var closeEligibleEpicsCmd = &cobra.Command{
} }
return return
} }
if dryRun { if dryRun {
if jsonOutput { if jsonOutput {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
@@ -174,7 +156,6 @@ var closeEligibleEpicsCmd = &cobra.Command{
} }
return return
} }
// Actually close the epics // Actually close the epics
closedIDs := []string{} closedIDs := []string{}
for _, epicStatus := range eligibleEpics { for _, epicStatus := range eligibleEpics {
@@ -203,7 +184,6 @@ var closeEligibleEpicsCmd = &cobra.Command{
} }
closedIDs = append(closedIDs, epicStatus.Epic.ID) closedIDs = append(closedIDs, epicStatus.Epic.ID)
} }
if jsonOutput { if jsonOutput {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ") enc.SetIndent("", " ")
@@ -222,16 +202,10 @@ var closeEligibleEpicsCmd = &cobra.Command{
} }
}, },
} }
func init() { func init() {
epicCmd.AddCommand(epicStatusCmd) epicCmd.AddCommand(epicStatusCmd)
epicCmd.AddCommand(closeEligibleEpicsCmd) epicCmd.AddCommand(closeEligibleEpicsCmd)
epicStatusCmd.Flags().Bool("eligible-only", false, "Show only epics eligible for closure") epicStatusCmd.Flags().Bool("eligible-only", false, "Show only epics eligible for closure")
epicStatusCmd.Flags().Bool("json", false, "Output in JSON format")
closeEligibleEpicsCmd.Flags().Bool("dry-run", false, "Preview what would be closed without making changes") closeEligibleEpicsCmd.Flags().Bool("dry-run", false, "Preview what would be closed without making changes")
closeEligibleEpicsCmd.Flags().Bool("json", false, "Output in JSON format")
rootCmd.AddCommand(epicCmd) rootCmd.AddCommand(epicCmd)
} }
-43
View File
@@ -1,6 +1,5 @@
// Package main implements the bd CLI label management commands. // Package main implements the bd CLI label management commands.
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
@@ -8,25 +7,21 @@ import (
"os" "os"
"sort" "sort"
"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/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/utils"
) )
var labelCmd = &cobra.Command{ var labelCmd = &cobra.Command{
Use: "label", Use: "label",
Short: "Manage issue labels", Short: "Manage issue labels",
} }
// Helper function to process label operations for multiple issues // Helper function to process label operations for multiple issues
func processBatchLabelOperation(issueIDs []string, label string, operation string, jsonOut bool, func processBatchLabelOperation(issueIDs []string, label string, operation string, jsonOut bool,
daemonFunc func(string, string) error, storeFunc func(context.Context, string, string, string) error) { daemonFunc func(string, string) error, storeFunc func(context.Context, string, string, string) error) {
ctx := context.Background() ctx := context.Background()
results := []map[string]interface{}{} results := []map[string]interface{}{}
for _, issueID := range issueIDs { for _, issueID := range issueIDs {
var err error var err error
if daemonClient != nil { if daemonClient != nil {
@@ -34,12 +29,10 @@ func processBatchLabelOperation(issueIDs []string, label string, operation strin
} else { } else {
err = storeFunc(ctx, issueID, label, actor) err = storeFunc(ctx, issueID, label, actor)
} }
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error %s label %s %s: %v\n", operation, operation, issueID, err) fmt.Fprintf(os.Stderr, "Error %s label %s %s: %v\n", operation, operation, issueID, err)
continue continue
} }
if jsonOut { if jsonOut {
results = append(results, map[string]interface{}{ results = append(results, map[string]interface{}{
"status": operation, "status": operation,
@@ -57,22 +50,18 @@ func processBatchLabelOperation(issueIDs []string, label string, operation strin
fmt.Printf("%s %s label '%s' %s %s\n", green("✓"), verb, label, prep, issueID) fmt.Printf("%s %s label '%s' %s %s\n", green("✓"), verb, label, prep, issueID)
} }
} }
if len(issueIDs) > 0 && daemonClient == nil { if len(issueIDs) > 0 && daemonClient == nil {
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
if jsonOut && len(results) > 0 { if jsonOut && len(results) > 0 {
outputJSON(results) outputJSON(results)
} }
} }
func parseLabelArgs(args []string) (issueIDs []string, label string) { func parseLabelArgs(args []string) (issueIDs []string, label string) {
label = args[len(args)-1] label = args[len(args)-1]
issueIDs = args[:len(args)-1] issueIDs = args[:len(args)-1]
return return
} }
//nolint:dupl // labelAddCmd and labelRemoveCmd are similar but serve different operations //nolint:dupl // labelAddCmd and labelRemoveCmd are similar but serve different operations
var labelAddCmd = &cobra.Command{ var labelAddCmd = &cobra.Command{
Use: "add [issue-id...] [label]", Use: "add [issue-id...] [label]",
@@ -81,14 +70,12 @@ var labelAddCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
issueIDs, label := parseLabelArgs(args) issueIDs, label := parseLabelArgs(args)
// Resolve partial IDs // Resolve partial IDs
ctx := context.Background() ctx := context.Background()
resolvedIDs := make([]string, 0, len(issueIDs)) resolvedIDs := make([]string, 0, len(issueIDs))
for _, id := range issueIDs { for _, id := range issueIDs {
var fullID string var fullID string
var err error var err error
if daemonClient != nil { if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: id} resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs) resp, err := daemonClient.ResolveID(resolveArgs)
@@ -107,7 +94,6 @@ var labelAddCmd = &cobra.Command{
resolvedIDs = append(resolvedIDs, fullID) resolvedIDs = append(resolvedIDs, fullID)
} }
issueIDs = resolvedIDs issueIDs = resolvedIDs
processBatchLabelOperation(issueIDs, label, "added", jsonOutput, processBatchLabelOperation(issueIDs, label, "added", jsonOutput,
func(issueID, lbl string) error { func(issueID, lbl string) error {
_, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: issueID, Label: lbl}) _, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: issueID, Label: lbl})
@@ -118,7 +104,6 @@ var labelAddCmd = &cobra.Command{
}) })
}, },
} }
//nolint:dupl // labelRemoveCmd and labelAddCmd are similar but serve different operations //nolint:dupl // labelRemoveCmd and labelAddCmd are similar but serve different operations
var labelRemoveCmd = &cobra.Command{ var labelRemoveCmd = &cobra.Command{
Use: "remove [issue-id...] [label]", Use: "remove [issue-id...] [label]",
@@ -127,14 +112,12 @@ var labelRemoveCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
issueIDs, label := parseLabelArgs(args) issueIDs, label := parseLabelArgs(args)
// Resolve partial IDs // Resolve partial IDs
ctx := context.Background() ctx := context.Background()
resolvedIDs := make([]string, 0, len(issueIDs)) resolvedIDs := make([]string, 0, len(issueIDs))
for _, id := range issueIDs { for _, id := range issueIDs {
var fullID string var fullID string
var err error var err error
if daemonClient != nil { if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: id} resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs) resp, err := daemonClient.ResolveID(resolveArgs)
@@ -153,7 +136,6 @@ var labelRemoveCmd = &cobra.Command{
resolvedIDs = append(resolvedIDs, fullID) resolvedIDs = append(resolvedIDs, fullID)
} }
issueIDs = resolvedIDs issueIDs = resolvedIDs
processBatchLabelOperation(issueIDs, label, "removed", jsonOutput, processBatchLabelOperation(issueIDs, label, "removed", jsonOutput,
func(issueID, lbl string) error { func(issueID, lbl string) error {
_, err := daemonClient.RemoveLabel(&rpc.LabelRemoveArgs{ID: issueID, Label: lbl}) _, err := daemonClient.RemoveLabel(&rpc.LabelRemoveArgs{ID: issueID, Label: lbl})
@@ -164,7 +146,6 @@ var labelRemoveCmd = &cobra.Command{
}) })
}, },
} }
var labelListCmd = &cobra.Command{ var labelListCmd = &cobra.Command{
Use: "list [issue-id]", Use: "list [issue-id]",
Short: "List labels for an issue", Short: "List labels for an issue",
@@ -172,7 +153,6 @@ var labelListCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
ctx := context.Background() ctx := context.Background()
// Resolve partial ID first // Resolve partial ID first
var issueID string var issueID string
if daemonClient != nil { if daemonClient != nil {
@@ -191,9 +171,7 @@ var labelListCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
var labels []string var labels []string
// Use daemon if available // Use daemon if available
if daemonClient != nil { if daemonClient != nil {
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID}) resp, err := daemonClient.Show(&rpc.ShowArgs{ID: issueID})
@@ -201,7 +179,6 @@ var labelListCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
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 {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
@@ -217,7 +194,6 @@ var labelListCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
if jsonOutput { if jsonOutput {
// Always output array, even if empty // Always output array, even if empty
if labels == nil { if labels == nil {
@@ -226,12 +202,10 @@ var labelListCmd = &cobra.Command{
outputJSON(labels) outputJSON(labels)
return return
} }
if len(labels) == 0 { if len(labels) == 0 {
fmt.Printf("\n%s has no labels\n", issueID) fmt.Printf("\n%s has no labels\n", issueID)
return return
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Labels for %s:\n", cyan("🏷"), issueID) fmt.Printf("\n%s Labels for %s:\n", cyan("🏷"), issueID)
for _, label := range labels { for _, label := range labels {
@@ -240,17 +214,14 @@ var labelListCmd = &cobra.Command{
fmt.Println() fmt.Println()
}, },
} }
var labelListAllCmd = &cobra.Command{ var labelListAllCmd = &cobra.Command{
Use: "list-all", Use: "list-all",
Short: "List all unique labels in the database", Short: "List all unique labels in the database",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
ctx := context.Background() ctx := context.Background()
var issues []*types.Issue var issues []*types.Issue
var err error var err error
// Use daemon if available // Use daemon if available
if daemonClient != nil { if daemonClient != nil {
resp, err := daemonClient.List(&rpc.ListArgs{}) resp, err := daemonClient.List(&rpc.ListArgs{})
@@ -258,7 +229,6 @@ var labelListAllCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if err := json.Unmarshal(resp.Data, &issues); err != nil { if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -271,7 +241,6 @@ var labelListAllCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
} }
// Collect unique labels with counts // Collect unique labels with counts
labelCounts := make(map[string]int) labelCounts := make(map[string]int)
for _, issue := range issues { for _, issue := range issues {
@@ -292,7 +261,6 @@ var labelListAllCmd = &cobra.Command{
} }
} }
} }
if len(labelCounts) == 0 { if len(labelCounts) == 0 {
if jsonOutput { if jsonOutput {
outputJSON([]string{}) outputJSON([]string{})
@@ -301,14 +269,12 @@ var labelListAllCmd = &cobra.Command{
} }
return return
} }
// Sort labels alphabetically // Sort labels alphabetically
labels := make([]string, 0, len(labelCounts)) labels := make([]string, 0, len(labelCounts))
for label := range labelCounts { for label := range labelCounts {
labels = append(labels, label) labels = append(labels, label)
} }
sort.Strings(labels) sort.Strings(labels)
if jsonOutput { if jsonOutput {
// Output as array of {label, count} objects // Output as array of {label, count} objects
type labelInfo struct { type labelInfo struct {
@@ -325,10 +291,8 @@ var labelListAllCmd = &cobra.Command{
outputJSON(result) outputJSON(result)
return return
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s All labels (%d unique):\n", cyan("🏷"), len(labels)) fmt.Printf("\n%s All labels (%d unique):\n", cyan("🏷"), len(labels))
// Find longest label for alignment // Find longest label for alignment
maxLen := 0 maxLen := 0
for _, label := range labels { for _, label := range labels {
@@ -336,7 +300,6 @@ var labelListAllCmd = &cobra.Command{
maxLen = len(label) maxLen = len(label)
} }
} }
for _, label := range labels { for _, label := range labels {
padding := strings.Repeat(" ", maxLen-len(label)) padding := strings.Repeat(" ", maxLen-len(label))
fmt.Printf(" %s%s (%d issues)\n", label, padding, labelCounts[label]) fmt.Printf(" %s%s (%d issues)\n", label, padding, labelCounts[label])
@@ -344,13 +307,7 @@ var labelListAllCmd = &cobra.Command{
fmt.Println() fmt.Println()
}, },
} }
func init() { func init() {
labelAddCmd.Flags().Bool("json", false, "Output JSON format")
labelRemoveCmd.Flags().Bool("json", false, "Output JSON format")
labelListCmd.Flags().Bool("json", false, "Output JSON format")
labelListAllCmd.Flags().Bool("json", false, "Output JSON format")
labelCmd.AddCommand(labelAddCmd) labelCmd.AddCommand(labelAddCmd)
labelCmd.AddCommand(labelRemoveCmd) labelCmd.AddCommand(labelRemoveCmd)
labelCmd.AddCommand(labelListCmd) labelCmd.AddCommand(labelListCmd)
+1 -1
View File
@@ -240,7 +240,7 @@ func init() {
listCmd.Flags().IntP("limit", "n", 0, "Limit results") listCmd.Flags().IntP("limit", "n", 0, "Limit results")
listCmd.Flags().String("format", "", "Output format: 'digraph' (for golang.org/x/tools/cmd/digraph), 'dot' (Graphviz), or Go template") listCmd.Flags().String("format", "", "Output format: 'digraph' (for golang.org/x/tools/cmd/digraph), 'dot' (Graphviz), or Go template")
listCmd.Flags().Bool("all", false, "Show all issues (default behavior; flag provided for CLI familiarity)") listCmd.Flags().Bool("all", false, "Show all issues (default behavior; flag provided for CLI familiarity)")
listCmd.Flags().Bool("json", false, "Output JSON format") // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(listCmd) rootCmd.AddCommand(listCmd)
} }
-43
View File
@@ -1,5 +1,4 @@
package main package main
import ( import (
"context" "context"
"fmt" "fmt"
@@ -7,23 +6,19 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
var mergeCmd = &cobra.Command{ var mergeCmd = &cobra.Command{
Use: "merge [source-id...] --into [target-id]", Use: "merge [source-id...] --into [target-id]",
Short: "Merge duplicate issues into a single issue", Short: "Merge duplicate issues into a single issue",
Long: `Merge one or more source issues into a target issue. Long: `Merge one or more source issues into a target issue.
This command is idempotent and safe to retry after partial failures: This command is idempotent and safe to retry after partial failures:
1. Validates all issues exist and no self-merge 1. Validates all issues exist and no self-merge
2. Migrates all dependencies from sources to target (skips if already exist) 2. Migrates all dependencies from sources to target (skips if already exist)
3. Updates text references in all issue descriptions/notes 3. Updates text references in all issue descriptions/notes
4. Closes source issues with reason 'Merged into bd-X' (skips if already closed) 4. Closes source issues with reason 'Merged into bd-X' (skips if already closed)
Example: Example:
bd merge bd-42 bd-43 --into bd-41 bd merge bd-42 bd-43 --into bd-41
bd merge bd-10 bd-11 bd-12 --into bd-10 --dry-run`, bd merge bd-10 bd-11 bd-12 --into bd-10 --dry-run`,
@@ -34,26 +29,21 @@ Example:
fmt.Fprintf(os.Stderr, "Error: merge command not yet supported in daemon mode (see bd-190)\n") fmt.Fprintf(os.Stderr, "Error: merge command not yet supported in daemon mode (see bd-190)\n")
os.Exit(1) os.Exit(1)
} }
targetID, _ := cmd.Flags().GetString("into") targetID, _ := cmd.Flags().GetString("into")
if targetID == "" { if targetID == "" {
fmt.Fprintf(os.Stderr, "Error: --into flag is required\n") fmt.Fprintf(os.Stderr, "Error: --into flag is required\n")
os.Exit(1) os.Exit(1)
} }
sourceIDs := args sourceIDs := args
dryRun, _ := cmd.Flags().GetBool("dry-run") dryRun, _ := cmd.Flags().GetBool("dry-run")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Validate merge operation // Validate merge operation
if err := validateMerge(targetID, sourceIDs); err != nil { if err := validateMerge(targetID, sourceIDs); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Direct mode // Direct mode
ctx := context.Background() ctx := context.Background()
if dryRun { if dryRun {
if !jsonOutput { if !jsonOutput {
fmt.Println("Dry run - validation passed, no changes made") fmt.Println("Dry run - validation passed, no changes made")
@@ -61,17 +51,14 @@ Example:
} }
return return
} }
// Perform merge // Perform merge
result, err := performMerge(ctx, targetID, sourceIDs) result, err := performMerge(ctx, targetID, sourceIDs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error performing merge: %v\n", err) fmt.Fprintf(os.Stderr, "Error performing merge: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Schedule auto-flush // Schedule auto-flush
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
if jsonOutput { if jsonOutput {
output := map[string]interface{}{ output := map[string]interface{}{
"target_id": targetID, "target_id": targetID,
@@ -93,18 +80,14 @@ Example:
} }
}, },
} }
func init() { func init() {
mergeCmd.Flags().String("into", "", "Target issue ID to merge into (required)") mergeCmd.Flags().String("into", "", "Target issue ID to merge into (required)")
mergeCmd.Flags().Bool("dry-run", false, "Validate without making changes") mergeCmd.Flags().Bool("dry-run", false, "Validate without making changes")
mergeCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(mergeCmd) rootCmd.AddCommand(mergeCmd)
} }
// validateMerge checks that merge operation is valid // validateMerge checks that merge operation is valid
func validateMerge(targetID string, sourceIDs []string) error { func validateMerge(targetID string, sourceIDs []string) error {
ctx := context.Background() ctx := context.Background()
// Check target exists // Check target exists
target, err := store.GetIssue(ctx, targetID) target, err := store.GetIssue(ctx, targetID)
if err != nil { if err != nil {
@@ -113,13 +96,11 @@ func validateMerge(targetID string, sourceIDs []string) error {
if target == nil { if target == nil {
return fmt.Errorf("target issue not found: %s", targetID) return fmt.Errorf("target issue not found: %s", targetID)
} }
// Check all sources exist and validate no self-merge // Check all sources exist and validate no self-merge
for _, sourceID := range sourceIDs { for _, sourceID := range sourceIDs {
if sourceID == targetID { if sourceID == targetID {
return fmt.Errorf("cannot merge issue into itself: %s", sourceID) return fmt.Errorf("cannot merge issue into itself: %s", sourceID)
} }
source, err := store.GetIssue(ctx, sourceID) source, err := store.GetIssue(ctx, sourceID)
if err != nil { if err != nil {
return fmt.Errorf("source issue not found: %s", sourceID) return fmt.Errorf("source issue not found: %s", sourceID)
@@ -128,10 +109,8 @@ func validateMerge(targetID string, sourceIDs []string) error {
return fmt.Errorf("source issue not found: %s", sourceID) return fmt.Errorf("source issue not found: %s", sourceID)
} }
} }
return nil return nil
} }
// mergeResult tracks the results of a merge operation for reporting // mergeResult tracks the results of a merge operation for reporting
type mergeResult struct { type mergeResult struct {
depsAdded int depsAdded int
@@ -140,12 +119,10 @@ type mergeResult struct {
issuesClosed int issuesClosed int
issuesSkipped int issuesSkipped int
} }
// performMerge executes the merge operation // performMerge executes the merge operation
// TODO(bd-202): Add transaction support for atomicity // TODO(bd-202): Add transaction support for atomicity
func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*mergeResult, error) { func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*mergeResult, error) {
result := &mergeResult{} result := &mergeResult{}
// Step 1: Migrate dependencies from source issues to target // Step 1: Migrate dependencies from source issues to target
for _, sourceID := range sourceIDs { for _, sourceID := range sourceIDs {
// Get all dependencies where source is the dependent (source depends on X) // Get all dependencies where source is the dependent (source depends on X)
@@ -153,7 +130,6 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get dependencies for %s: %w", sourceID, err) return nil, fmt.Errorf("failed to get dependencies for %s: %w", sourceID, err)
} }
// Migrate each dependency to target // Migrate each dependency to target
for _, dep := range deps { for _, dep := range deps {
// Skip if target already has this dependency // Skip if target already has this dependency
@@ -161,7 +137,6 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to check target dependencies: %w", err) return nil, fmt.Errorf("failed to check target dependencies: %w", err)
} }
alreadyExists := false alreadyExists := false
for _, existing := range existingDeps { for _, existing := range existingDeps {
if existing.DependsOnID == dep.DependsOnID && existing.Type == dep.Type { if existing.DependsOnID == dep.DependsOnID && existing.Type == dep.Type {
@@ -169,7 +144,6 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
break break
} }
} }
if alreadyExists || dep.DependsOnID == targetID { if alreadyExists || dep.DependsOnID == targetID {
result.depsSkipped++ result.depsSkipped++
} else { } else {
@@ -187,13 +161,11 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
result.depsAdded++ result.depsAdded++
} }
} }
// Get all dependencies where source is the dependency (X depends on source) // Get all dependencies where source is the dependency (X depends on source)
allDeps, err := store.GetAllDependencyRecords(ctx) allDeps, err := store.GetAllDependencyRecords(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get all dependencies: %w", err) return nil, fmt.Errorf("failed to get all dependencies: %w", err)
} }
for issueID, depList := range allDeps { for issueID, depList := range allDeps {
for _, dep := range depList { for _, dep := range depList {
if dep.DependsOnID == sourceID { if dep.DependsOnID == sourceID {
@@ -204,7 +176,6 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
return nil, fmt.Errorf("failed to remove dependency %s -> %s: %w", issueID, sourceID, err) return nil, fmt.Errorf("failed to remove dependency %s -> %s: %w", issueID, sourceID, err)
} }
} }
// Add new dependency to target (if not self-reference) // Add new dependency to target (if not self-reference)
if issueID != targetID { if issueID != targetID {
newDep := &types.Dependency{ newDep := &types.Dependency{
@@ -228,14 +199,12 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
} }
} }
} }
// Step 2: Update text references in all issues // Step 2: Update text references in all issues
refCount, err := updateMergeTextReferences(ctx, sourceIDs, targetID) refCount, err := updateMergeTextReferences(ctx, sourceIDs, targetID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to update text references: %w", err) return nil, fmt.Errorf("failed to update text references: %w", err)
} }
result.textRefCount = refCount result.textRefCount = refCount
// Step 3: Close source issues (idempotent - skip if already closed) // Step 3: Close source issues (idempotent - skip if already closed)
for _, sourceID := range sourceIDs { for _, sourceID := range sourceIDs {
issue, err := store.GetIssue(ctx, sourceID) issue, err := store.GetIssue(ctx, sourceID)
@@ -245,7 +214,6 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
if issue == nil { if issue == nil {
return nil, fmt.Errorf("source issue not found: %s", sourceID) return nil, fmt.Errorf("source issue not found: %s", sourceID)
} }
if issue.Status == types.StatusClosed { if issue.Status == types.StatusClosed {
// Already closed - skip // Already closed - skip
result.issuesSkipped++ result.issuesSkipped++
@@ -257,10 +225,8 @@ func performMerge(ctx context.Context, targetID string, sourceIDs []string) (*me
result.issuesClosed++ result.issuesClosed++
} }
} }
return result, nil return result, nil
} }
// updateMergeTextReferences updates text references from source IDs to target ID // updateMergeTextReferences updates text references from source IDs to target ID
// Returns the count of text references updated // Returns the count of text references updated
func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID string) (int, error) { func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID string) (int, error) {
@@ -269,7 +235,6 @@ func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to get all issues: %w", err) return 0, fmt.Errorf("failed to get all issues: %w", err)
} }
updatedCount := 0 updatedCount := 0
for _, issue := range allIssues { for _, issue := range allIssues {
// Skip source issues (they're being closed anyway) // Skip source issues (they're being closed anyway)
@@ -283,16 +248,13 @@ func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID
if isSource { if isSource {
continue continue
} }
updates := make(map[string]interface{}) updates := make(map[string]interface{})
// Check each source ID for references // Check each source ID for references
for _, sourceID := range sourceIDs { for _, sourceID := range sourceIDs {
// Build regex pattern to match issue IDs with word boundaries // Build regex pattern to match issue IDs with word boundaries
idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(sourceID) + `)($|[^A-Za-z0-9_-])` idPattern := `(^|[^A-Za-z0-9_-])(` + regexp.QuoteMeta(sourceID) + `)($|[^A-Za-z0-9_-])`
re := regexp.MustCompile(idPattern) re := regexp.MustCompile(idPattern)
replacementText := `$1` + targetID + `$3` replacementText := `$1` + targetID + `$3`
// Update description // Update description
if issue.Description != "" && re.MatchString(issue.Description) { if issue.Description != "" && re.MatchString(issue.Description) {
if _, exists := updates["description"]; !exists { if _, exists := updates["description"]; !exists {
@@ -302,7 +264,6 @@ func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID
updates["description"] = re.ReplaceAllString(desc, replacementText) updates["description"] = re.ReplaceAllString(desc, replacementText)
} }
} }
// Update notes // Update notes
if issue.Notes != "" && re.MatchString(issue.Notes) { if issue.Notes != "" && re.MatchString(issue.Notes) {
if _, exists := updates["notes"]; !exists { if _, exists := updates["notes"]; !exists {
@@ -312,7 +273,6 @@ func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID
updates["notes"] = re.ReplaceAllString(notes, replacementText) updates["notes"] = re.ReplaceAllString(notes, replacementText)
} }
} }
// Update design // Update design
if issue.Design != "" && re.MatchString(issue.Design) { if issue.Design != "" && re.MatchString(issue.Design) {
if _, exists := updates["design"]; !exists { if _, exists := updates["design"]; !exists {
@@ -322,7 +282,6 @@ func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID
updates["design"] = re.ReplaceAllString(design, replacementText) updates["design"] = re.ReplaceAllString(design, replacementText)
} }
} }
// Update acceptance criteria // Update acceptance criteria
if issue.AcceptanceCriteria != "" && re.MatchString(issue.AcceptanceCriteria) { if issue.AcceptanceCriteria != "" && re.MatchString(issue.AcceptanceCriteria) {
if _, exists := updates["acceptance_criteria"]; !exists { if _, exists := updates["acceptance_criteria"]; !exists {
@@ -333,7 +292,6 @@ func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID
} }
} }
} }
// Apply updates if any // Apply updates if any
if len(updates) > 0 { if len(updates) > 0 {
if err := store.UpdateIssue(ctx, issue.ID, updates, actor); err != nil { if err := store.UpdateIssue(ctx, issue.ID, updates, actor); err != nil {
@@ -342,6 +300,5 @@ func updateMergeTextReferences(ctx context.Context, sourceIDs []string, targetID
updatedCount++ updatedCount++
} }
} }
return updatedCount, nil return updatedCount, nil
} }
-42
View File
@@ -1,18 +1,15 @@
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"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/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
var readyCmd = &cobra.Command{ var readyCmd = &cobra.Command{
Use: "ready", Use: "ready",
Short: "Show ready work (no blockers, open or in-progress)", Short: "Show ready work (no blockers, open or in-progress)",
@@ -21,7 +18,6 @@ var readyCmd = &cobra.Command{
assignee, _ := cmd.Flags().GetString("assignee") assignee, _ := cmd.Flags().GetString("assignee")
sortPolicy, _ := cmd.Flags().GetString("sort") sortPolicy, _ := cmd.Flags().GetString("sort")
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars) // Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
filter := types.WorkFilter{ filter := types.WorkFilter{
// Leave Status empty to get both 'open' and 'in_progress' (bd-165) // Leave Status empty to get both 'open' and 'in_progress' (bd-165)
Limit: limit, Limit: limit,
@@ -35,13 +31,11 @@ var readyCmd = &cobra.Command{
if assignee != "" { if assignee != "" {
filter.Assignee = &assignee filter.Assignee = &assignee
} }
// Validate sort policy // Validate sort policy
if !filter.SortPolicy.IsValid() { if !filter.SortPolicy.IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid sort policy '%s'. Valid values: hybrid, priority, oldest\n", sortPolicy) fmt.Fprintf(os.Stderr, "Error: invalid sort policy '%s'. Valid values: hybrid, priority, oldest\n", sortPolicy)
os.Exit(1) os.Exit(1)
} }
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
readyArgs := &rpc.ReadyArgs{ readyArgs := &rpc.ReadyArgs{
@@ -53,19 +47,16 @@ var readyCmd = &cobra.Command{
priority, _ := cmd.Flags().GetInt("priority") priority, _ := cmd.Flags().GetInt("priority")
readyArgs.Priority = &priority readyArgs.Priority = &priority
} }
resp, err := daemonClient.Ready(readyArgs) resp, err := daemonClient.Ready(readyArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
var issues []*types.Issue var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil { if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if jsonOutput { if jsonOutput {
if issues == nil { if issues == nil {
issues = []*types.Issue{} issues = []*types.Issue{}
@@ -73,17 +64,14 @@ var readyCmd = &cobra.Command{
outputJSON(issues) outputJSON(issues)
return return
} }
if len(issues) == 0 { if len(issues) == 0 {
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n", fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
yellow("✨")) yellow("✨"))
return return
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues)) fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues))
for i, issue := range issues { for i, issue := range issues {
fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title) fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
if issue.EstimatedMinutes != nil { if issue.EstimatedMinutes != nil {
@@ -96,7 +84,6 @@ var readyCmd = &cobra.Command{
fmt.Println() fmt.Println()
return return
} }
// Direct mode // Direct mode
ctx := context.Background() ctx := context.Background()
issues, err := store.GetReadyWork(ctx, filter) issues, err := store.GetReadyWork(ctx, filter)
@@ -104,7 +91,6 @@ var readyCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// If no ready work found, check if git has issues and auto-import // If no ready work found, check if git has issues and auto-import
if len(issues) == 0 { if len(issues) == 0 {
if checkAndAutoImport(ctx, store) { if checkAndAutoImport(ctx, store) {
@@ -116,7 +102,6 @@ var readyCmd = &cobra.Command{
} }
} }
} }
if jsonOutput { if jsonOutput {
// Always output array, even if empty // Always output array, even if empty
if issues == nil { if issues == nil {
@@ -125,17 +110,14 @@ var readyCmd = &cobra.Command{
outputJSON(issues) outputJSON(issues)
return return
} }
if len(issues) == 0 { if len(issues) == 0 {
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n", fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
yellow("✨")) yellow("✨"))
return return
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues)) fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues))
for i, issue := range issues { for i, issue := range issues {
fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title) fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
if issue.EstimatedMinutes != nil { if issue.EstimatedMinutes != nil {
@@ -148,13 +130,11 @@ var readyCmd = &cobra.Command{
fmt.Println() fmt.Println()
}, },
} }
var blockedCmd = &cobra.Command{ var blockedCmd = &cobra.Command{
Use: "blocked", Use: "blocked",
Short: "Show blocked issues", Short: "Show blocked issues",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars) // Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// If daemon is running but doesn't support this command, use direct storage // If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil { if daemonClient != nil && store == nil {
var err error var err error
@@ -165,14 +145,12 @@ var blockedCmd = &cobra.Command{
} }
defer func() { _ = store.Close() }() defer func() { _ = store.Close() }()
} }
ctx := context.Background() ctx := context.Background()
blocked, err := store.GetBlockedIssues(ctx) blocked, err := store.GetBlockedIssues(ctx)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if jsonOutput { if jsonOutput {
// Always output array, even if empty // Always output array, even if empty
if blocked == nil { if blocked == nil {
@@ -181,16 +159,13 @@ var blockedCmd = &cobra.Command{
outputJSON(blocked) outputJSON(blocked)
return return
} }
if len(blocked) == 0 { if len(blocked) == 0 {
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s No blocked issues\n\n", green("✨")) fmt.Printf("\n%s No blocked issues\n\n", green("✨"))
return return
} }
red := color.New(color.FgRed).SprintFunc() red := color.New(color.FgRed).SprintFunc()
fmt.Printf("\n%s Blocked issues (%d):\n\n", red("🚫"), len(blocked)) fmt.Printf("\n%s Blocked issues (%d):\n\n", red("🚫"), len(blocked))
for _, issue := range blocked { for _, issue := range blocked {
fmt.Printf("[P%d] %s: %s\n", issue.Priority, issue.ID, issue.Title) fmt.Printf("[P%d] %s: %s\n", issue.Priority, issue.ID, issue.Title)
blockedBy := issue.BlockedBy blockedBy := issue.BlockedBy
@@ -203,13 +178,11 @@ var blockedCmd = &cobra.Command{
} }
}, },
} }
var statsCmd = &cobra.Command{ var statsCmd = &cobra.Command{
Use: "stats", Use: "stats",
Short: "Show statistics", Short: "Show statistics",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars) // Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
resp, err := daemonClient.Stats() resp, err := daemonClient.Stats()
@@ -217,22 +190,18 @@ var statsCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
var stats types.Statistics var stats types.Statistics
if err := json.Unmarshal(resp.Data, &stats); err != nil { if err := json.Unmarshal(resp.Data, &stats); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if jsonOutput { if jsonOutput {
outputJSON(stats) outputJSON(stats)
return return
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊")) fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊"))
fmt.Printf("Total Issues: %d\n", stats.TotalIssues) fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues))) fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues)))
@@ -246,7 +215,6 @@ var statsCmd = &cobra.Command{
fmt.Println() fmt.Println()
return return
} }
// Direct mode // Direct mode
ctx := context.Background() ctx := context.Background()
stats, err := store.GetStatistics(ctx) stats, err := store.GetStatistics(ctx)
@@ -254,7 +222,6 @@ var statsCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// If no issues found, check if git has issues and auto-import // If no issues found, check if git has issues and auto-import
if stats.TotalIssues == 0 { if stats.TotalIssues == 0 {
if checkAndAutoImport(ctx, store) { if checkAndAutoImport(ctx, store) {
@@ -266,16 +233,13 @@ var statsCmd = &cobra.Command{
} }
} }
} }
if jsonOutput { if jsonOutput {
outputJSON(stats) outputJSON(stats)
return return
} }
cyan := color.New(color.FgCyan).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊")) fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊"))
fmt.Printf("Total Issues: %d\n", stats.TotalIssues) fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues))) fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues)))
@@ -292,17 +256,11 @@ var statsCmd = &cobra.Command{
fmt.Println() fmt.Println()
}, },
} }
func init() { func init() {
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show") readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority") readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest") readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest")
readyCmd.Flags().Bool("json", false, "Output JSON format")
statsCmd.Flags().Bool("json", false, "Output JSON format")
blockedCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(readyCmd) rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd) rootCmd.AddCommand(blockedCmd)
rootCmd.AddCommand(statsCmd) rootCmd.AddCommand(statsCmd)
-21
View File
@@ -1,31 +1,25 @@
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"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/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/utils"
) )
var reopenCmd = &cobra.Command{ var reopenCmd = &cobra.Command{
Use: "reopen [id...]", Use: "reopen [id...]",
Short: "Reopen one or more closed issues", Short: "Reopen one or more closed issues",
Long: `Reopen closed issues by setting status to 'open' and clearing the closed_at timestamp. Long: `Reopen closed issues by setting status to 'open' and clearing the closed_at timestamp.
This is more explicit than 'bd update --status open' and emits a Reopened event.`, This is more explicit than 'bd update --status open' and emits a Reopened event.`,
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
reason, _ := cmd.Flags().GetString("reason") reason, _ := cmd.Flags().GetString("reason")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
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 {
@@ -46,9 +40,7 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
os.Exit(1) os.Exit(1)
} }
} }
reopenedIssues := []*types.Issue{} reopenedIssues := []*types.Issue{}
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
for _, id := range resolvedIDs { for _, id := range resolvedIDs {
@@ -57,18 +49,15 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
ID: id, ID: id,
Status: &openStatus, Status: &openStatus,
} }
resp, err := daemonClient.Update(updateArgs) resp, err := daemonClient.Update(updateArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error reopening %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error reopening %s: %v\n", id, err)
continue continue
} }
// TODO: Add reason as a comment once RPC supports AddComment // TODO: Add reason as a comment once RPC supports AddComment
if reason != "" { if reason != "" {
fmt.Fprintf(os.Stderr, "Warning: reason not supported in daemon mode yet\n") fmt.Fprintf(os.Stderr, "Warning: reason not supported in daemon mode yet\n")
} }
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 {
@@ -83,26 +72,22 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
fmt.Printf("%s Reopened %s%s\n", blue("↻"), id, reasonMsg) fmt.Printf("%s Reopened %s%s\n", blue("↻"), id, reasonMsg)
} }
} }
if jsonOutput && len(reopenedIssues) > 0 { if jsonOutput && len(reopenedIssues) > 0 {
outputJSON(reopenedIssues) outputJSON(reopenedIssues)
} }
return return
} }
// Fall back to direct storage access // Fall back to direct storage access
if store == nil { if store == nil {
fmt.Fprintln(os.Stderr, "Error: database not initialized") fmt.Fprintln(os.Stderr, "Error: database not initialized")
os.Exit(1) os.Exit(1)
} }
for _, id := range args { for _, id := range args {
fullID, err := utils.ResolvePartialID(ctx, store, id) fullID, err := utils.ResolvePartialID(ctx, store, id)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err) fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue continue
} }
// UpdateIssue automatically clears closed_at when status changes from closed // UpdateIssue automatically clears closed_at when status changes from closed
updates := map[string]interface{}{ updates := map[string]interface{}{
"status": string(types.StatusOpen), "status": string(types.StatusOpen),
@@ -111,14 +96,12 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
fmt.Fprintf(os.Stderr, "Error reopening %s: %v\n", fullID, err) fmt.Fprintf(os.Stderr, "Error reopening %s: %v\n", fullID, err)
continue continue
} }
// Add reason as a comment if provided // Add reason as a comment if provided
if reason != "" { if reason != "" {
if err := store.AddComment(ctx, fullID, actor, reason); err != nil { if err := store.AddComment(ctx, fullID, actor, reason); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to add comment to %s: %v\n", fullID, err) fmt.Fprintf(os.Stderr, "Warning: failed to add comment to %s: %v\n", fullID, err)
} }
} }
if jsonOutput { if jsonOutput {
issue, _ := store.GetIssue(ctx, fullID) issue, _ := store.GetIssue(ctx, fullID)
if issue != nil { if issue != nil {
@@ -133,20 +116,16 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
fmt.Printf("%s Reopened %s%s\n", blue("↻"), fullID, reasonMsg) fmt.Printf("%s Reopened %s%s\n", blue("↻"), fullID, reasonMsg)
} }
} }
// Schedule auto-flush if any issues were reopened // Schedule auto-flush if any issues were reopened
if len(args) > 0 { if len(args) > 0 {
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
if jsonOutput && len(reopenedIssues) > 0 { if jsonOutput && len(reopenedIssues) > 0 {
outputJSON(reopenedIssues) outputJSON(reopenedIssues)
} }
}, },
} }
func init() { func init() {
reopenCmd.Flags().StringP("reason", "r", "", "Reason for reopening") reopenCmd.Flags().StringP("reason", "r", "", "Reason for reopening")
reopenCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(reopenCmd) rootCmd.AddCommand(reopenCmd)
} }
-87
View File
@@ -1,5 +1,4 @@
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
@@ -7,7 +6,6 @@ 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"
@@ -15,7 +13,6 @@ import (
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/utils"
) )
var showCmd = &cobra.Command{ var showCmd = &cobra.Command{
Use: "show [id...]", Use: "show [id...]",
Short: "Show issue details", Short: "Show issue details",
@@ -23,7 +20,6 @@ var showCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
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 {
@@ -46,7 +42,6 @@ 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{}{}
@@ -57,7 +52,6 @@ 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
@@ -78,7 +72,6 @@ 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
@@ -92,9 +85,7 @@ 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 := ""
@@ -106,7 +97,6 @@ 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)
@@ -119,7 +109,6 @@ 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()
@@ -142,7 +131,6 @@ 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)
} }
@@ -155,35 +143,29 @@ 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("\nDepends on (%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 [P%d]\n", dep.ID, dep.Title, dep.Priority) fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
} }
} }
if len(details.Dependents) > 0 { if len(details.Dependents) > 0 {
fmt.Printf("\nBlocks (%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 [P%d]\n", dep.ID, dep.Title, dep.Priority) fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, 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 {
@@ -196,7 +178,6 @@ 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 (with metadata), dependents (with metadata), and comments in JSON output
type IssueDetails struct { type IssueDetails struct {
@@ -208,7 +189,6 @@ var showCmd = &cobra.Command{
} }
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) // Get dependencies with metadata (type, created_at, created_by)
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok { if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
details.Dependencies, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID) details.Dependencies, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID)
@@ -224,18 +204,14 @@ var showCmd = &cobra.Command{
details.Dependents = append(details.Dependents, &types.IssueWithDependencyMetadata{Issue: *dependent}) 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 := ""
@@ -247,7 +223,6 @@ 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,7 +235,6 @@ 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 := "🗜️"
@@ -268,7 +242,6 @@ 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)
@@ -285,7 +258,6 @@ 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)
} }
@@ -298,13 +270,11 @@ 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 // Show dependencies
deps, _ := store.GetDependencies(ctx, issue.ID) deps, _ := store.GetDependencies(ctx, issue.ID)
if len(deps) > 0 { if len(deps) > 0 {
@@ -313,7 +283,6 @@ var showCmd = &cobra.Command{
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority) fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
} }
} }
// Show dependents // Show dependents
dependents, _ := store.GetDependents(ctx, issue.ID) dependents, _ := store.GetDependents(ctx, issue.ID)
if len(dependents) > 0 { if len(dependents) > 0 {
@@ -322,7 +291,6 @@ var showCmd = &cobra.Command{
fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority) fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, 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 {
@@ -331,16 +299,13 @@ 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",
@@ -348,7 +313,6 @@ var updateCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
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
@@ -390,14 +354,11 @@ 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 {
@@ -418,13 +379,11 @@ 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
@@ -450,13 +409,11 @@ 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
} }
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 {
@@ -467,13 +424,11 @@ 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 {
@@ -481,7 +436,6 @@ var updateCmd = &cobra.Command{
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 {
@@ -492,25 +446,20 @@ 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
@@ -521,7 +470,6 @@ 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)
@@ -531,7 +479,6 @@ 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") {
@@ -543,7 +490,6 @@ 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 == "" {
@@ -562,11 +508,9 @@ 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}
@@ -575,7 +519,6 @@ 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)
@@ -593,7 +536,6 @@ 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 {
@@ -608,7 +550,6 @@ 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 {
@@ -617,7 +558,6 @@ 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() // nolint:gosec // G104: Error already handled above
@@ -625,18 +565,15 @@ Examples:
os.Exit(1) os.Exit(1)
} }
_ = tmpFile.Close() // nolint:gosec // G104: Defer close errors are non-critical _ = tmpFile.Close() // nolint:gosec // G104: Defer close errors are non-critical
// 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 // nolint:gosec // G304: tmpPath is securely created temp file
editedContent, err := os.ReadFile(tmpPath) editedContent, err := os.ReadFile(tmpPath)
@@ -644,30 +581,24 @@ Examples:
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
@@ -680,7 +611,6 @@ 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)
@@ -694,13 +624,11 @@ 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",
@@ -711,9 +639,7 @@ var closeCmd = &cobra.Command{
reason = "Closed" reason = "Closed"
} }
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
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 {
@@ -734,7 +660,6 @@ 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{}
@@ -748,7 +673,6 @@ 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 {
@@ -759,13 +683,11 @@ 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 {
@@ -783,22 +705,17 @@ 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().IntP("priority", "p", 0, "New priority") updateCmd.Flags().IntP("priority", "p", 0, "New priority")
updateCmd.Flags().String("title", "", "New title") updateCmd.Flags().String("title", "", "New title")
@@ -810,17 +727,13 @@ 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)
} }
-20
View File
@@ -1,23 +1,19 @@
package main package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"time" "time"
"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/types" "github.com/steveyegge/beads/internal/types"
) )
var staleCmd = &cobra.Command{ var staleCmd = &cobra.Command{
Use: "stale", Use: "stale",
Short: "Show stale issues (not updated recently)", Short: "Show stale issues (not updated recently)",
Long: `Show issues that haven't been updated recently and may need attention. Long: `Show issues that haven't been updated recently and may need attention.
This helps identify: This helps identify:
- In-progress issues with no recent activity (may be abandoned) - In-progress issues with no recent activity (may be abandoned)
- Open issues that have been forgotten - Open issues that have been forgotten
@@ -27,19 +23,16 @@ This helps identify:
status, _ := cmd.Flags().GetString("status") status, _ := cmd.Flags().GetString("status")
limit, _ := cmd.Flags().GetInt("limit") limit, _ := cmd.Flags().GetInt("limit")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Validate status if provided // Validate status if provided
if status != "" && status != "open" && status != "in_progress" && status != "blocked" { if status != "" && status != "open" && status != "in_progress" && status != "blocked" {
fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked\n", status) fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked\n", status)
os.Exit(1) os.Exit(1)
} }
filter := types.StaleFilter{ filter := types.StaleFilter{
Days: days, Days: days,
Status: status, Status: status,
Limit: limit, Limit: limit,
} }
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
staleArgs := &rpc.StaleArgs{ staleArgs := &rpc.StaleArgs{
@@ -47,19 +40,16 @@ This helps identify:
Status: status, Status: status,
Limit: limit, Limit: limit,
} }
resp, err := daemonClient.Stale(staleArgs) resp, err := daemonClient.Stale(staleArgs)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
var issues []*types.Issue var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil { if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if jsonOutput { if jsonOutput {
if issues == nil { if issues == nil {
issues = []*types.Issue{} issues = []*types.Issue{}
@@ -67,11 +57,9 @@ This helps identify:
outputJSON(issues) outputJSON(issues)
return return
} }
displayStaleIssues(issues, days) displayStaleIssues(issues, days)
return return
} }
// Direct mode // Direct mode
ctx := context.Background() ctx := context.Background()
issues, err := store.GetStaleIssues(ctx, filter) issues, err := store.GetStaleIssues(ctx, filter)
@@ -79,7 +67,6 @@ This helps identify:
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if jsonOutput { if jsonOutput {
if issues == nil { if issues == nil {
issues = []*types.Issue{} issues = []*types.Issue{}
@@ -87,21 +74,17 @@ This helps identify:
outputJSON(issues) outputJSON(issues)
return return
} }
displayStaleIssues(issues, days) displayStaleIssues(issues, days)
}, },
} }
func displayStaleIssues(issues []*types.Issue, days int) { func displayStaleIssues(issues []*types.Issue, days int) {
if len(issues) == 0 { if len(issues) == 0 {
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s No stale issues found (all active)\n\n", green("✨")) fmt.Printf("\n%s No stale issues found (all active)\n\n", green("✨"))
return return
} }
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Stale issues (%d not updated in %d+ days):\n\n", yellow("⏰"), len(issues), days) fmt.Printf("\n%s Stale issues (%d not updated in %d+ days):\n\n", yellow("⏰"), len(issues), days)
now := time.Now() now := time.Now()
for i, issue := range issues { for i, issue := range issues {
daysStale := int(now.Sub(issue.UpdatedAt).Hours() / 24) daysStale := int(now.Sub(issue.UpdatedAt).Hours() / 24)
@@ -113,12 +96,9 @@ func displayStaleIssues(issues []*types.Issue, days int) {
fmt.Println() fmt.Println()
} }
} }
func init() { func init() {
staleCmd.Flags().IntP("days", "d", 30, "Issues not updated in this many days") staleCmd.Flags().IntP("days", "d", 30, "Issues not updated in this many days")
staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked)") staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked)")
staleCmd.Flags().IntP("limit", "n", 50, "Maximum issues to show") staleCmd.Flags().IntP("limit", "n", 50, "Maximum issues to show")
staleCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(staleCmd) rootCmd.AddCommand(staleCmd)
} }
+1 -1
View File
@@ -259,6 +259,6 @@ func getAssignedStatus(assignee string) *StatusSummary {
func init() { func init() {
statusCmd.Flags().Bool("all", false, "Show all issues (default behavior)") statusCmd.Flags().Bool("all", false, "Show all issues (default behavior)")
statusCmd.Flags().Bool("assigned", false, "Show issues assigned to current user") statusCmd.Flags().Bool("assigned", false, "Show issues assigned to current user")
statusCmd.Flags().Bool("json", false, "Output in JSON format") // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(statusCmd)
} }
-66
View File
@@ -1,16 +1,13 @@
package main package main
import ( import (
"context" "context"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
var validateCmd = &cobra.Command{ var validateCmd = &cobra.Command{
Use: "validate", Use: "validate",
Short: "Run comprehensive database health checks", Short: "Run comprehensive database health checks",
@@ -19,7 +16,6 @@ var validateCmd = &cobra.Command{
- Duplicate issues (identical content) - Duplicate issues (identical content)
- Test pollution (leaked test issues) - Test pollution (leaked test issues)
- Git merge conflicts in JSONL - Git merge conflicts in JSONL
Example: Example:
bd validate # Run all checks bd validate # Run all checks
bd validate --fix-all # Auto-fix all issues bd validate --fix-all # Auto-fix all issues
@@ -33,13 +29,10 @@ Example:
fmt.Fprintf(os.Stderr, "Use: bd --no-daemon validate\n") fmt.Fprintf(os.Stderr, "Use: bd --no-daemon validate\n")
os.Exit(1) os.Exit(1)
} }
fixAll, _ := cmd.Flags().GetBool("fix-all") fixAll, _ := cmd.Flags().GetBool("fix-all")
checksFlag, _ := cmd.Flags().GetString("checks") checksFlag, _ := cmd.Flags().GetString("checks")
jsonOut, _ := cmd.Flags().GetBool("json") jsonOut, _ := cmd.Flags().GetBool("json")
ctx := context.Background() ctx := context.Background()
// Parse and normalize checks // Parse and normalize checks
checks, err := parseChecks(checksFlag) checks, err := parseChecks(checksFlag)
if err != nil { if err != nil {
@@ -47,7 +40,6 @@ Example:
fmt.Fprintf(os.Stderr, "Valid checks: orphans, duplicates, pollution, conflicts\n") fmt.Fprintf(os.Stderr, "Valid checks: orphans, duplicates, pollution, conflicts\n")
os.Exit(2) os.Exit(2)
} }
// Fetch all issues once for checks that need them // Fetch all issues once for checks that need them
var allIssues []*types.Issue var allIssues []*types.Issue
needsIssues := false needsIssues := false
@@ -64,12 +56,10 @@ Example:
os.Exit(1) os.Exit(1)
} }
} }
results := validationResults{ results := validationResults{
checks: make(map[string]checkResult), checks: make(map[string]checkResult),
checkOrder: checks, checkOrder: checks,
} }
// Run each check // Run each check
for _, check := range checks { for _, check := range checks {
switch check { switch check {
@@ -83,50 +73,41 @@ Example:
results.checks["conflicts"] = validateGitConflicts(ctx, fixAll) results.checks["conflicts"] = validateGitConflicts(ctx, fixAll)
} }
} }
// Output results // Output results
if jsonOut { if jsonOut {
outputJSON(results.toJSON()) outputJSON(results.toJSON())
} else { } else {
results.print(fixAll) results.print(fixAll)
} }
// Exit with error code if issues found or errors occurred // Exit with error code if issues found or errors occurred
if results.hasFailures() { if results.hasFailures() {
os.Exit(1) os.Exit(1)
} }
}, },
} }
// parseChecks normalizes and validates check names // parseChecks normalizes and validates check names
func parseChecks(checksFlag string) ([]string, error) { func parseChecks(checksFlag string) ([]string, error) {
defaultChecks := []string{"orphans", "duplicates", "pollution", "conflicts"} defaultChecks := []string{"orphans", "duplicates", "pollution", "conflicts"}
if checksFlag == "" { if checksFlag == "" {
return defaultChecks, nil return defaultChecks, nil
} }
// Map of synonyms to canonical names // Map of synonyms to canonical names
synonyms := map[string]string{ synonyms := map[string]string{
"dupes": "duplicates", "dupes": "duplicates",
"git-conflicts": "conflicts", "git-conflicts": "conflicts",
} }
var result []string var result []string
seen := make(map[string]bool) seen := make(map[string]bool)
parts := strings.Split(checksFlag, ",") parts := strings.Split(checksFlag, ",")
for _, part := range parts { for _, part := range parts {
check := strings.ToLower(strings.TrimSpace(part)) check := strings.ToLower(strings.TrimSpace(part))
if check == "" { if check == "" {
continue continue
} }
// Map synonyms // Map synonyms
if canonical, ok := synonyms[check]; ok { if canonical, ok := synonyms[check]; ok {
check = canonical check = canonical
} }
// Validate // Validate
valid := false valid := false
for _, validCheck := range defaultChecks { for _, validCheck := range defaultChecks {
@@ -138,17 +119,14 @@ func parseChecks(checksFlag string) ([]string, error) {
if !valid { if !valid {
return nil, fmt.Errorf("unknown check: %s", part) return nil, fmt.Errorf("unknown check: %s", part)
} }
// Deduplicate // Deduplicate
if !seen[check] { if !seen[check] {
seen[check] = true seen[check] = true
result = append(result, check) result = append(result, check)
} }
} }
return result, nil return result, nil
} }
type checkResult struct { type checkResult struct {
name string name string
issueCount int issueCount int
@@ -156,12 +134,10 @@ type checkResult struct {
err error err error
suggestions []string suggestions []string
} }
type validationResults struct { type validationResults struct {
checks map[string]checkResult checks map[string]checkResult
checkOrder []string checkOrder []string
} }
func (r *validationResults) hasFailures() bool { func (r *validationResults) hasFailures() bool {
for _, result := range r.checks { for _, result := range r.checks {
if result.err != nil { if result.err != nil {
@@ -173,23 +149,19 @@ func (r *validationResults) hasFailures() bool {
} }
return false return false
} }
func (r *validationResults) toJSON() map[string]interface{} { func (r *validationResults) toJSON() map[string]interface{} {
output := map[string]interface{}{ output := map[string]interface{}{
"checks": map[string]interface{}{}, "checks": map[string]interface{}{},
} }
totalIssues := 0 totalIssues := 0
totalFixed := 0 totalFixed := 0
hasErrors := false hasErrors := false
for name, result := range r.checks { for name, result := range r.checks {
var errorStr interface{} var errorStr interface{}
if result.err != nil { if result.err != nil {
errorStr = result.err.Error() errorStr = result.err.Error()
hasErrors = true hasErrors = true
} }
output["checks"].(map[string]interface{})[name] = map[string]interface{}{ output["checks"].(map[string]interface{})[name] = map[string]interface{}{
"issue_count": result.issueCount, "issue_count": result.issueCount,
"fixed_count": result.fixedCount, "fixed_count": result.fixedCount,
@@ -200,31 +172,24 @@ func (r *validationResults) toJSON() map[string]interface{} {
totalIssues += result.issueCount totalIssues += result.issueCount
totalFixed += result.fixedCount totalFixed += result.fixedCount
} }
output["total_issues"] = totalIssues output["total_issues"] = totalIssues
output["total_fixed"] = totalFixed output["total_fixed"] = totalFixed
output["healthy"] = !hasErrors && (totalIssues == 0 || totalIssues == totalFixed) output["healthy"] = !hasErrors && (totalIssues == 0 || totalIssues == totalFixed)
return output return output
} }
func (r *validationResults) print(_ bool) { func (r *validationResults) print(_ bool) {
green := color.New(color.FgGreen).SprintFunc() green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc()
red := color.New(color.FgRed).SprintFunc() red := color.New(color.FgRed).SprintFunc()
fmt.Println("\nValidation Results:") fmt.Println("\nValidation Results:")
fmt.Println("===================") fmt.Println("===================")
totalIssues := 0 totalIssues := 0
totalFixed := 0 totalFixed := 0
// Print in deterministic order // Print in deterministic order
for _, name := range r.checkOrder { for _, name := range r.checkOrder {
result := r.checks[name] result := r.checks[name]
prefix := "✓" prefix := "✓"
colorFunc := green colorFunc := green
if result.err != nil { if result.err != nil {
prefix = "✗" prefix = "✗"
colorFunc = red colorFunc = red
@@ -240,13 +205,10 @@ func (r *validationResults) print(_ bool) {
} else { } else {
fmt.Printf("%s %s: OK\n", colorFunc(prefix), result.name) fmt.Printf("%s %s: OK\n", colorFunc(prefix), result.name)
} }
totalIssues += result.issueCount totalIssues += result.issueCount
totalFixed += result.fixedCount totalFixed += result.fixedCount
} }
fmt.Println() fmt.Println()
if totalIssues == 0 { if totalIssues == 0 {
fmt.Printf("%s Database is healthy!\n", green("✓")) fmt.Printf("%s Database is healthy!\n", green("✓"))
} else if totalFixed == totalIssues { } else if totalFixed == totalIssues {
@@ -258,7 +220,6 @@ func (r *validationResults) print(_ bool) {
fmt.Printf(" (fixed %d, %d remaining)", totalFixed, remaining) fmt.Printf(" (fixed %d, %d remaining)", totalFixed, remaining)
} }
fmt.Println() fmt.Println()
// Print suggestions // Print suggestions
fmt.Println("\nRecommendations:") fmt.Println("\nRecommendations:")
for _, result := range r.checks { for _, result := range r.checks {
@@ -268,23 +229,19 @@ func (r *validationResults) print(_ bool) {
} }
} }
} }
func validateOrphanedDeps(ctx context.Context, allIssues []*types.Issue, fix bool) checkResult { func validateOrphanedDeps(ctx context.Context, allIssues []*types.Issue, fix bool) checkResult {
result := checkResult{name: "orphaned dependencies"} result := checkResult{name: "orphaned dependencies"}
// Build ID existence map // Build ID existence map
existingIDs := make(map[string]bool) existingIDs := make(map[string]bool)
for _, issue := range allIssues { for _, issue := range allIssues {
existingIDs[issue.ID] = true existingIDs[issue.ID] = true
} }
// Find orphaned dependencies // Find orphaned dependencies
type orphanedDep struct { type orphanedDep struct {
issueID string issueID string
orphanedID string orphanedID string
} }
var orphaned []orphanedDep var orphaned []orphanedDep
for _, issue := range allIssues { for _, issue := range allIssues {
for _, dep := range issue.Dependencies { for _, dep := range issue.Dependencies {
if !existingIDs[dep.DependsOnID] { if !existingIDs[dep.DependsOnID] {
@@ -295,16 +252,13 @@ func validateOrphanedDeps(ctx context.Context, allIssues []*types.Issue, fix boo
} }
} }
} }
result.issueCount = len(orphaned) result.issueCount = len(orphaned)
if fix && len(orphaned) > 0 { if fix && len(orphaned) > 0 {
// Group by issue // Group by issue
orphansByIssue := make(map[string][]string) orphansByIssue := make(map[string][]string)
for _, o := range orphaned { for _, o := range orphaned {
orphansByIssue[o.issueID] = append(orphansByIssue[o.issueID], o.orphanedID) orphansByIssue[o.issueID] = append(orphansByIssue[o.issueID], o.orphanedID)
} }
// Fix each issue // Fix each issue
for issueID, orphanedIDs := range orphansByIssue { for issueID, orphanedIDs := range orphansByIssue {
for _, orphanedID := range orphanedIDs { for _, orphanedID := range orphanedIDs {
@@ -313,30 +267,23 @@ func validateOrphanedDeps(ctx context.Context, allIssues []*types.Issue, fix boo
} }
} }
} }
if result.fixedCount > 0 { if result.fixedCount > 0 {
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()
} }
} }
if result.issueCount > result.fixedCount { if result.issueCount > result.fixedCount {
result.suggestions = append(result.suggestions, "Run 'bd repair-deps --fix' to remove orphaned dependencies") result.suggestions = append(result.suggestions, "Run 'bd repair-deps --fix' to remove orphaned dependencies")
} }
return result return result
} }
func validateDuplicates(_ context.Context, allIssues []*types.Issue, fix bool) checkResult { func validateDuplicates(_ context.Context, allIssues []*types.Issue, fix bool) checkResult {
result := checkResult{name: "duplicates"} result := checkResult{name: "duplicates"}
// Find duplicates // Find duplicates
duplicateGroups := findDuplicateGroups(allIssues) duplicateGroups := findDuplicateGroups(allIssues)
// Count total duplicate issues (excluding one canonical per group) // Count total duplicate issues (excluding one canonical per group)
for _, group := range duplicateGroups { for _, group := range duplicateGroups {
result.issueCount += len(group) - 1 result.issueCount += len(group) - 1
} }
if fix && len(duplicateGroups) > 0 { if fix && len(duplicateGroups) > 0 {
// Note: Auto-merge is complex and requires user review // Note: Auto-merge is complex and requires user review
// We don't auto-fix duplicates, just report them // We don't auto-fix duplicates, just report them
@@ -346,17 +293,13 @@ func validateDuplicates(_ context.Context, allIssues []*types.Issue, fix bool) c
result.suggestions = append(result.suggestions, result.suggestions = append(result.suggestions,
fmt.Sprintf("Run 'bd duplicates' to review %d duplicate groups", len(duplicateGroups))) fmt.Sprintf("Run 'bd duplicates' to review %d duplicate groups", len(duplicateGroups)))
} }
return result return result
} }
func validatePollution(_ context.Context, allIssues []*types.Issue, fix bool) checkResult { func validatePollution(_ context.Context, allIssues []*types.Issue, fix bool) checkResult {
result := checkResult{name: "test pollution"} result := checkResult{name: "test pollution"}
// Detect pollution // Detect pollution
polluted := detectTestPollution(allIssues) polluted := detectTestPollution(allIssues)
result.issueCount = len(polluted) result.issueCount = len(polluted)
if fix && len(polluted) > 0 { if fix && len(polluted) > 0 {
// Note: Deleting issues is destructive, we just suggest it // Note: Deleting issues is destructive, we just suggest it
result.suggestions = append(result.suggestions, result.suggestions = append(result.suggestions,
@@ -365,13 +308,10 @@ func validatePollution(_ context.Context, allIssues []*types.Issue, fix bool) ch
result.suggestions = append(result.suggestions, result.suggestions = append(result.suggestions,
fmt.Sprintf("Run 'bd detect-pollution' to review %d potential test issues", len(polluted))) fmt.Sprintf("Run 'bd detect-pollution' to review %d potential test issues", len(polluted)))
} }
return result return result
} }
func validateGitConflicts(_ context.Context, fix bool) checkResult { func validateGitConflicts(_ context.Context, fix bool) checkResult {
result := checkResult{name: "git conflicts"} result := checkResult{name: "git conflicts"}
// Check JSONL file for conflict markers // Check JSONL file for conflict markers
jsonlPath := findJSONLPath() jsonlPath := findJSONLPath()
// nolint:gosec // G304: jsonlPath is validated JSONL file from findJSONLPath // nolint:gosec // G304: jsonlPath is validated JSONL file from findJSONLPath
@@ -384,7 +324,6 @@ func validateGitConflicts(_ context.Context, fix bool) checkResult {
result.err = fmt.Errorf("failed to read JSONL: %w", err) result.err = fmt.Errorf("failed to read JSONL: %w", err)
return result return result
} }
// Look for git conflict markers // Look for git conflict markers
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
var conflictLines []int var conflictLines []int
@@ -396,7 +335,6 @@ func validateGitConflicts(_ context.Context, fix bool) checkResult {
conflictLines = append(conflictLines, i+1) conflictLines = append(conflictLines, i+1)
} }
} }
if len(conflictLines) > 0 { if len(conflictLines) > 0 {
result.issueCount = 1 // One conflict situation result.issueCount = 1 // One conflict situation
result.suggestions = append(result.suggestions, result.suggestions = append(result.suggestions,
@@ -410,19 +348,15 @@ func validateGitConflicts(_ context.Context, fix bool) checkResult {
result.suggestions = append(result.suggestions, result.suggestions = append(result.suggestions,
"For advanced field-level merging: https://github.com/neongreen/mono/tree/main/beads-merge") "For advanced field-level merging: https://github.com/neongreen/mono/tree/main/beads-merge")
} }
// Can't auto-fix git conflicts // Can't auto-fix git conflicts
if fix && result.issueCount > 0 { if fix && result.issueCount > 0 {
result.suggestions = append(result.suggestions, result.suggestions = append(result.suggestions,
"Note: Git conflicts cannot be auto-fixed with --fix-all") "Note: Git conflicts cannot be auto-fixed with --fix-all")
} }
return result return result
} }
func init() { func init() {
validateCmd.Flags().Bool("fix-all", false, "Auto-fix all fixable issues") validateCmd.Flags().Bool("fix-all", false, "Auto-fix all fixable issues")
validateCmd.Flags().String("checks", "", "Comma-separated list of checks (orphans,duplicates,pollution,conflicts)") validateCmd.Flags().String("checks", "", "Comma-separated list of checks (orphans,duplicates,pollution,conflicts)")
validateCmd.Flags().Bool("json", false, "Output in JSON format")
rootCmd.AddCommand(validateCmd) rootCmd.AddCommand(validateCmd)
} }