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() {
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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}