From f92a741501e37f108bc11278c781a96173ecc6ff Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 20 Dec 2025 08:57:20 -0800 Subject: [PATCH] refactor(status): merge stats command into status (GH#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate `bd stats` and `bd status` into a unified command: - Add `stats` as alias for `status` command - Add colorized output with emoji header - Include all Statistics fields (tombstones, pinned, epics, lead time) - Add `--no-activity` flag to skip git activity parsing - Remove standalone statsCmd from ready.go (~90 lines) - Update StatusOutput to use types.Statistics directly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/ready.go | 92 +---------------------------- cmd/bd/status.go | 132 +++++++++++++++++++++++------------------- cmd/bd/status_test.go | 48 ++++++--------- 3 files changed, 93 insertions(+), 179 deletions(-) diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index 4376b685..ab05c962 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -251,96 +251,7 @@ var blockedCmd = &cobra.Command{ } }, } -var statsCmd = &cobra.Command{ - Use: "stats", - Short: "Show statistics", - Run: func(cmd *cobra.Command, args []string) { - // Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars) - // If daemon is running, use RPC - if daemonClient != nil { - resp, err := daemonClient.Stats() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - var stats types.Statistics - if err := json.Unmarshal(resp.Data, &stats); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) - os.Exit(1) - } - if jsonOutput { - outputJSON(stats) - return - } - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊")) - fmt.Printf("Total Issues: %d\n", stats.TotalIssues) - fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues))) - fmt.Printf("In Progress: %s\n", yellow(fmt.Sprintf("%d", stats.InProgressIssues))) - fmt.Printf("Closed: %d\n", stats.ClosedIssues) - fmt.Printf("Blocked: %d\n", stats.BlockedIssues) - fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues))) - if stats.TombstoneIssues > 0 { - fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) - } - if stats.PinnedIssues > 0 { - fmt.Printf("Pinned: %d\n", stats.PinnedIssues) - } - if stats.AverageLeadTime > 0 { - fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime) - } - fmt.Println() - return - } - // Direct mode - ctx := rootCtx - stats, err := store.GetStatistics(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - // If no issues found, check if git has issues and auto-import - if stats.TotalIssues == 0 { - if checkAndAutoImport(ctx, store) { - // Re-run the stats after import - stats, err = store.GetStatistics(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - } - } - if jsonOutput { - outputJSON(stats) - return - } - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊")) - fmt.Printf("Total Issues: %d\n", stats.TotalIssues) - fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues))) - fmt.Printf("In Progress: %s\n", yellow(fmt.Sprintf("%d", stats.InProgressIssues))) - fmt.Printf("Closed: %d\n", stats.ClosedIssues) - fmt.Printf("Blocked: %d\n", stats.BlockedIssues) - fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues))) - if stats.TombstoneIssues > 0 { - fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues) - } - if stats.PinnedIssues > 0 { - fmt.Printf("Pinned: %d\n", stats.PinnedIssues) - } - if stats.EpicsEligibleForClosure > 0 { - fmt.Printf("Epics Ready to Close: %s\n", green(fmt.Sprintf("%d", stats.EpicsEligibleForClosure))) - } - if stats.AverageLeadTime > 0 { - fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime) - } - fmt.Println() - }, -} + func init() { readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show") readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority") @@ -352,5 +263,4 @@ func init() { readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)") rootCmd.AddCommand(readyCmd) rootCmd.AddCommand(blockedCmd) - rootCmd.AddCommand(statsCmd) } diff --git a/cmd/bd/status.go b/cmd/bd/status.go index 1ab8234f..00cbde10 100644 --- a/cmd/bd/status.go +++ b/cmd/bd/status.go @@ -9,26 +9,17 @@ import ( "strings" "time" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" ) // StatusOutput represents the complete status output type StatusOutput struct { - Summary *StatusSummary `json:"summary"` + Summary *types.Statistics `json:"summary"` RecentActivity *RecentActivitySummary `json:"recent_activity,omitempty"` } -// StatusSummary represents counts by state -type StatusSummary struct { - TotalIssues int `json:"total_issues"` - OpenIssues int `json:"open_issues"` - InProgressIssues int `json:"in_progress_issues"` - BlockedIssues int `json:"blocked_issues"` - ClosedIssues int `json:"closed_issues"` - ReadyIssues int `json:"ready_issues"` -} - // RecentActivitySummary represents activity from git history type RecentActivitySummary struct { HoursTracked int `json:"hours_tracked"` @@ -41,12 +32,14 @@ type RecentActivitySummary struct { } var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show issue database overview", - Long: `Show a quick snapshot of the issue database state. + Use: "status", + Aliases: []string{"stats"}, + Short: "Show issue database overview and statistics", + Long: `Show a quick snapshot of the issue database state and statistics. This command provides a summary of issue counts by state (open, in_progress, -blocked, closed), ready work, and recent activity over the last 24 hours from git history. +blocked, closed), ready work, extended statistics (tombstones, pinned issues, +average lead time), and recent activity over the last 24 hours from git history. Similar to how 'git status' shows working tree state, 'bd status' gives you a quick overview of your issue database without needing multiple queries. @@ -58,13 +51,15 @@ Use cases: - Daily standup reference Examples: - bd status # Show summary + bd status # Show summary with activity + bd status --no-activity # Skip git activity (faster) bd status --json # JSON format output bd status --assigned # Show issues assigned to current user - bd status --all # Show all issues (same as default)`, + bd stats # Alias for bd status`, Run: func(cmd *cobra.Command, args []string) { showAll, _ := cmd.Flags().GetBool("all") showAssigned, _ := cmd.Flags().GetBool("assigned") + noActivity, _ := cmd.Flags().GetBool("no-activity") jsonFormat, _ := cmd.Flags().GetBool("json") // Override global jsonOutput if --json flag is set @@ -108,28 +103,23 @@ Examples: } } - // Build summary - summary := &StatusSummary{ - TotalIssues: stats.TotalIssues, - OpenIssues: stats.OpenIssues, - InProgressIssues: stats.InProgressIssues, - BlockedIssues: stats.BlockedIssues, - ClosedIssues: stats.ClosedIssues, - ReadyIssues: stats.ReadyIssues, + // Filter by assignee if requested (overrides stats with filtered counts) + if showAssigned { + stats = getAssignedStatistics(actor) + if stats == nil { + fmt.Fprintf(os.Stderr, "Error: failed to get assigned statistics\n") + os.Exit(1) + } } - // Get recent activity from git history (last 24 hours) + // Get recent activity from git history (last 24 hours) unless --no-activity var recentActivity *RecentActivitySummary - recentActivity = getGitActivity(24) - - // Filter by assignee if requested - if showAssigned { - // Get filtered statistics for assigned issues - summary = getAssignedStatus(actor) + if !noActivity { + recentActivity = getGitActivity(24) } output := &StatusOutput{ - Summary: summary, + Summary: stats, RecentActivity: recentActivity, } @@ -139,25 +129,48 @@ Examples: return } - // Human-readable output - fmt.Println("\nIssue Database Status") - fmt.Println("=====================") - fmt.Printf("\nSummary:\n") - fmt.Printf(" Total Issues: %d\n", summary.TotalIssues) - fmt.Printf(" Open: %d\n", summary.OpenIssues) - fmt.Printf(" In Progress: %d\n", summary.InProgressIssues) - fmt.Printf(" Blocked: %d\n", summary.BlockedIssues) - fmt.Printf(" Closed: %d\n", summary.ClosedIssues) - fmt.Printf(" Ready to Work: %d\n", summary.ReadyIssues) + // Human-readable colorized output + cyan := color.New(color.FgCyan).SprintFunc() + green := color.New(color.FgGreen).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + red := color.New(color.FgRed).SprintFunc() + + fmt.Printf("\n%s Issue Database Status\n\n", cyan("📊")) + fmt.Printf("Summary:\n") + fmt.Printf(" Total Issues: %d\n", stats.TotalIssues) + fmt.Printf(" Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues))) + fmt.Printf(" In Progress: %s\n", yellow(fmt.Sprintf("%d", stats.InProgressIssues))) + fmt.Printf(" Blocked: %s\n", red(fmt.Sprintf("%d", stats.BlockedIssues))) + fmt.Printf(" Closed: %d\n", stats.ClosedIssues) + fmt.Printf(" Ready to Work: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues))) + + // Extended statistics (only show if non-zero) + hasExtended := stats.TombstoneIssues > 0 || stats.PinnedIssues > 0 || + stats.EpicsEligibleForClosure > 0 || stats.AverageLeadTime > 0 + if hasExtended { + fmt.Printf("\nExtended:\n") + if stats.TombstoneIssues > 0 { + fmt.Printf(" Deleted: %d (tombstones)\n", stats.TombstoneIssues) + } + if stats.PinnedIssues > 0 { + fmt.Printf(" Pinned: %d\n", stats.PinnedIssues) + } + if stats.EpicsEligibleForClosure > 0 { + fmt.Printf(" Epics Ready to Close: %s\n", green(fmt.Sprintf("%d", stats.EpicsEligibleForClosure))) + } + if stats.AverageLeadTime > 0 { + fmt.Printf(" Avg Lead Time: %.1f hours\n", stats.AverageLeadTime) + } + } if recentActivity != nil { - fmt.Printf("\nRecent Activity (last %d hours, from git history):\n", recentActivity.HoursTracked) - fmt.Printf(" Commits: %d\n", recentActivity.CommitCount) - fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges) - fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated) - fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed) - fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened) - fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated) + fmt.Printf("\nRecent Activity (last %d hours):\n", recentActivity.HoursTracked) + fmt.Printf(" Commits: %d\n", recentActivity.CommitCount) + fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges) + fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated) + fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed) + fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened) + fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated) } // Show hint for more details @@ -267,8 +280,8 @@ func getGitActivity(hours int) *RecentActivitySummary { return activity } -// getAssignedStatus returns status summary for issues assigned to a specific user -func getAssignedStatus(assignee string) *StatusSummary { +// getAssignedStatistics returns statistics for issues assigned to a specific user +func getAssignedStatistics(assignee string) *types.Statistics { if store == nil { return nil } @@ -286,7 +299,7 @@ func getAssignedStatus(assignee string) *StatusSummary { return nil } - summary := &StatusSummary{ + stats := &types.Statistics{ TotalIssues: len(issues), } @@ -294,13 +307,13 @@ func getAssignedStatus(assignee string) *StatusSummary { for _, issue := range issues { switch issue.Status { case types.StatusOpen: - summary.OpenIssues++ + stats.OpenIssues++ case types.StatusInProgress: - summary.InProgressIssues++ + stats.InProgressIssues++ case types.StatusBlocked: - summary.BlockedIssues++ + stats.BlockedIssues++ case types.StatusClosed: - summary.ClosedIssues++ + stats.ClosedIssues++ } } @@ -310,15 +323,16 @@ func getAssignedStatus(assignee string) *StatusSummary { } readyIssues, err := store.GetReadyWork(ctx, readyFilter) if err == nil { - summary.ReadyIssues = len(readyIssues) + stats.ReadyIssues = len(readyIssues) } - return summary + return stats } func init() { statusCmd.Flags().Bool("all", false, "Show all issues (default behavior)") statusCmd.Flags().Bool("assigned", false, "Show issues assigned to current user") + statusCmd.Flags().Bool("no-activity", false, "Skip git activity tracking (faster)") // Note: --json flag is defined as a persistent flag in main.go, not here rootCmd.AddCommand(statusCmd) } diff --git a/cmd/bd/status_test.go b/cmd/bd/status_test.go index 6da3bd0c..ca8d4ea2 100644 --- a/cmd/bd/status_test.go +++ b/cmd/bd/status_test.go @@ -112,19 +112,9 @@ func TestStatusCommand(t *testing.T) { t.Errorf("Expected 1 closed issue, got %d", stats.ClosedIssues) } - // Test status output structures - summary := &StatusSummary{ - TotalIssues: stats.TotalIssues, - OpenIssues: stats.OpenIssues, - InProgressIssues: stats.InProgressIssues, - BlockedIssues: stats.BlockedIssues, - ClosedIssues: stats.ClosedIssues, - ReadyIssues: stats.ReadyIssues, - } - - // Test JSON marshaling + // Test JSON marshaling with full Statistics output := &StatusOutput{ - Summary: summary, + Summary: stats, } jsonBytes, err := json.MarshalIndent(output, "", " ") @@ -178,7 +168,7 @@ func TestGetGitActivity(t *testing.T) { } } -func TestGetAssignedStatus(t *testing.T) { +func TestGetAssignedStatistics(t *testing.T) { // Create a temporary directory for the test database tempDir := t.TempDir() dbPath := filepath.Join(tempDir, ".beads", "test.db") @@ -202,7 +192,7 @@ func TestGetAssignedStatus(t *testing.T) { t.Fatalf("Failed to set issue prefix: %v", err) } - // Set global store and rootCtx for getAssignedStatus + // Set global store and rootCtx for getAssignedStatistics oldRootCtx := rootCtx rootCtx = ctx defer func() { rootCtx = oldRootCtx }() @@ -239,29 +229,29 @@ func TestGetAssignedStatus(t *testing.T) { } } - // Test getAssignedStatus for Alice - summary := getAssignedStatus("alice") - if summary == nil { - t.Fatal("getAssignedStatus returned nil") + // Test getAssignedStatistics for Alice + stats := getAssignedStatistics("alice") + if stats == nil { + t.Fatal("getAssignedStatistics returned nil") } - if summary.TotalIssues != 2 { - t.Errorf("Expected 2 issues for alice, got %d", summary.TotalIssues) + if stats.TotalIssues != 2 { + t.Errorf("Expected 2 issues for alice, got %d", stats.TotalIssues) } - if summary.OpenIssues != 1 { - t.Errorf("Expected 1 open issue for alice, got %d", summary.OpenIssues) + if stats.OpenIssues != 1 { + t.Errorf("Expected 1 open issue for alice, got %d", stats.OpenIssues) } - if summary.InProgressIssues != 1 { - t.Errorf("Expected 1 in-progress issue for alice, got %d", summary.InProgressIssues) + if stats.InProgressIssues != 1 { + t.Errorf("Expected 1 in-progress issue for alice, got %d", stats.InProgressIssues) } // Test for Bob - bobSummary := getAssignedStatus("bob") - if bobSummary == nil { - t.Fatal("getAssignedStatus returned nil for bob") + bobStats := getAssignedStatistics("bob") + if bobStats == nil { + t.Fatal("getAssignedStatistics returned nil for bob") } - if bobSummary.TotalIssues != 1 { - t.Errorf("Expected 1 issue for bob, got %d", bobSummary.TotalIssues) + if bobStats.TotalIssues != 1 { + t.Errorf("Expected 1 issue for bob, got %d", bobStats.TotalIssues) } }