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:
+1
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user