refactor(status): merge stats command into status (GH#644)

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-20 08:57:20 -08:00
parent f6392efd07
commit f92a741501
3 changed files with 93 additions and 179 deletions

View File

@@ -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() { 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")
@@ -352,5 +263,4 @@ func init() {
readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)") readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)")
rootCmd.AddCommand(readyCmd) rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd) rootCmd.AddCommand(blockedCmd)
rootCmd.AddCommand(statsCmd)
} }

View File

@@ -9,26 +9,17 @@ import (
"strings" "strings"
"time" "time"
"github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
// StatusOutput represents the complete status output // StatusOutput represents the complete status output
type StatusOutput struct { type StatusOutput struct {
Summary *StatusSummary `json:"summary"` Summary *types.Statistics `json:"summary"`
RecentActivity *RecentActivitySummary `json:"recent_activity,omitempty"` 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 // RecentActivitySummary represents activity from git history
type RecentActivitySummary struct { type RecentActivitySummary struct {
HoursTracked int `json:"hours_tracked"` HoursTracked int `json:"hours_tracked"`
@@ -41,12 +32,14 @@ type RecentActivitySummary struct {
} }
var statusCmd = &cobra.Command{ var statusCmd = &cobra.Command{
Use: "status", Use: "status",
Short: "Show issue database overview", Aliases: []string{"stats"},
Long: `Show a quick snapshot of the issue database state. 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, 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 Similar to how 'git status' shows working tree state, 'bd status' gives you
a quick overview of your issue database without needing multiple queries. a quick overview of your issue database without needing multiple queries.
@@ -58,13 +51,15 @@ Use cases:
- Daily standup reference - Daily standup reference
Examples: 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 --json # JSON format output
bd status --assigned # Show issues assigned to current user 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) { Run: func(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all") showAll, _ := cmd.Flags().GetBool("all")
showAssigned, _ := cmd.Flags().GetBool("assigned") showAssigned, _ := cmd.Flags().GetBool("assigned")
noActivity, _ := cmd.Flags().GetBool("no-activity")
jsonFormat, _ := cmd.Flags().GetBool("json") jsonFormat, _ := cmd.Flags().GetBool("json")
// Override global jsonOutput if --json flag is set // Override global jsonOutput if --json flag is set
@@ -108,28 +103,23 @@ Examples:
} }
} }
// Build summary // Filter by assignee if requested (overrides stats with filtered counts)
summary := &StatusSummary{ if showAssigned {
TotalIssues: stats.TotalIssues, stats = getAssignedStatistics(actor)
OpenIssues: stats.OpenIssues, if stats == nil {
InProgressIssues: stats.InProgressIssues, fmt.Fprintf(os.Stderr, "Error: failed to get assigned statistics\n")
BlockedIssues: stats.BlockedIssues, os.Exit(1)
ClosedIssues: stats.ClosedIssues, }
ReadyIssues: stats.ReadyIssues,
} }
// Get recent activity from git history (last 24 hours) // Get recent activity from git history (last 24 hours) unless --no-activity
var recentActivity *RecentActivitySummary var recentActivity *RecentActivitySummary
recentActivity = getGitActivity(24) if !noActivity {
recentActivity = getGitActivity(24)
// Filter by assignee if requested
if showAssigned {
// Get filtered statistics for assigned issues
summary = getAssignedStatus(actor)
} }
output := &StatusOutput{ output := &StatusOutput{
Summary: summary, Summary: stats,
RecentActivity: recentActivity, RecentActivity: recentActivity,
} }
@@ -139,25 +129,48 @@ Examples:
return return
} }
// Human-readable output // Human-readable colorized output
fmt.Println("\nIssue Database Status") cyan := color.New(color.FgCyan).SprintFunc()
fmt.Println("=====================") green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\nSummary:\n") yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf(" Total Issues: %d\n", summary.TotalIssues) red := color.New(color.FgRed).SprintFunc()
fmt.Printf(" Open: %d\n", summary.OpenIssues)
fmt.Printf(" In Progress: %d\n", summary.InProgressIssues) fmt.Printf("\n%s Issue Database Status\n\n", cyan("📊"))
fmt.Printf(" Blocked: %d\n", summary.BlockedIssues) fmt.Printf("Summary:\n")
fmt.Printf(" Closed: %d\n", summary.ClosedIssues) fmt.Printf(" Total Issues: %d\n", stats.TotalIssues)
fmt.Printf(" Ready to Work: %d\n", summary.ReadyIssues) 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 { if recentActivity != nil {
fmt.Printf("\nRecent Activity (last %d hours, from git history):\n", recentActivity.HoursTracked) fmt.Printf("\nRecent Activity (last %d hours):\n", recentActivity.HoursTracked)
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount) fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges) fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated) fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed) fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened) fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated) fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
} }
// Show hint for more details // Show hint for more details
@@ -267,8 +280,8 @@ func getGitActivity(hours int) *RecentActivitySummary {
return activity return activity
} }
// getAssignedStatus returns status summary for issues assigned to a specific user // getAssignedStatistics returns statistics for issues assigned to a specific user
func getAssignedStatus(assignee string) *StatusSummary { func getAssignedStatistics(assignee string) *types.Statistics {
if store == nil { if store == nil {
return nil return nil
} }
@@ -286,7 +299,7 @@ func getAssignedStatus(assignee string) *StatusSummary {
return nil return nil
} }
summary := &StatusSummary{ stats := &types.Statistics{
TotalIssues: len(issues), TotalIssues: len(issues),
} }
@@ -294,13 +307,13 @@ func getAssignedStatus(assignee string) *StatusSummary {
for _, issue := range issues { for _, issue := range issues {
switch issue.Status { switch issue.Status {
case types.StatusOpen: case types.StatusOpen:
summary.OpenIssues++ stats.OpenIssues++
case types.StatusInProgress: case types.StatusInProgress:
summary.InProgressIssues++ stats.InProgressIssues++
case types.StatusBlocked: case types.StatusBlocked:
summary.BlockedIssues++ stats.BlockedIssues++
case types.StatusClosed: case types.StatusClosed:
summary.ClosedIssues++ stats.ClosedIssues++
} }
} }
@@ -310,15 +323,16 @@ func getAssignedStatus(assignee string) *StatusSummary {
} }
readyIssues, err := store.GetReadyWork(ctx, readyFilter) readyIssues, err := store.GetReadyWork(ctx, readyFilter)
if err == nil { if err == nil {
summary.ReadyIssues = len(readyIssues) stats.ReadyIssues = len(readyIssues)
} }
return summary return stats
} }
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("no-activity", false, "Skip git activity tracking (faster)")
// Note: --json flag is defined as a persistent flag in main.go, not here // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(statusCmd)
} }

View File

@@ -112,19 +112,9 @@ func TestStatusCommand(t *testing.T) {
t.Errorf("Expected 1 closed issue, got %d", stats.ClosedIssues) t.Errorf("Expected 1 closed issue, got %d", stats.ClosedIssues)
} }
// Test status output structures // Test JSON marshaling with full Statistics
summary := &StatusSummary{
TotalIssues: stats.TotalIssues,
OpenIssues: stats.OpenIssues,
InProgressIssues: stats.InProgressIssues,
BlockedIssues: stats.BlockedIssues,
ClosedIssues: stats.ClosedIssues,
ReadyIssues: stats.ReadyIssues,
}
// Test JSON marshaling
output := &StatusOutput{ output := &StatusOutput{
Summary: summary, Summary: stats,
} }
jsonBytes, err := json.MarshalIndent(output, "", " ") 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 // Create a temporary directory for the test database
tempDir := t.TempDir() tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, ".beads", "test.db") 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) 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 oldRootCtx := rootCtx
rootCtx = ctx rootCtx = ctx
defer func() { rootCtx = oldRootCtx }() defer func() { rootCtx = oldRootCtx }()
@@ -239,29 +229,29 @@ func TestGetAssignedStatus(t *testing.T) {
} }
} }
// Test getAssignedStatus for Alice // Test getAssignedStatistics for Alice
summary := getAssignedStatus("alice") stats := getAssignedStatistics("alice")
if summary == nil { if stats == nil {
t.Fatal("getAssignedStatus returned nil") t.Fatal("getAssignedStatistics returned nil")
} }
if summary.TotalIssues != 2 { if stats.TotalIssues != 2 {
t.Errorf("Expected 2 issues for alice, got %d", summary.TotalIssues) t.Errorf("Expected 2 issues for alice, got %d", stats.TotalIssues)
} }
if summary.OpenIssues != 1 { if stats.OpenIssues != 1 {
t.Errorf("Expected 1 open issue for alice, got %d", summary.OpenIssues) t.Errorf("Expected 1 open issue for alice, got %d", stats.OpenIssues)
} }
if summary.InProgressIssues != 1 { if stats.InProgressIssues != 1 {
t.Errorf("Expected 1 in-progress issue for alice, got %d", summary.InProgressIssues) t.Errorf("Expected 1 in-progress issue for alice, got %d", stats.InProgressIssues)
} }
// Test for Bob // Test for Bob
bobSummary := getAssignedStatus("bob") bobStats := getAssignedStatistics("bob")
if bobSummary == nil { if bobStats == nil {
t.Fatal("getAssignedStatus returned nil for bob") t.Fatal("getAssignedStatistics returned nil for bob")
} }
if bobSummary.TotalIssues != 1 { if bobStats.TotalIssues != 1 {
t.Errorf("Expected 1 issue for bob, got %d", bobSummary.TotalIssues) t.Errorf("Expected 1 issue for bob, got %d", bobStats.TotalIssues)
} }
} }