diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 84c9570f..569322cf 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -34,6 +34,7 @@ {"id":"bd-2530","content_hash":"7056a386ee4802bce2b83a981aaac7858b5911938263d212f8f9d1f60bf2a706","title":"Issue with labels","description":"This is a description","design":"Use MVC pattern","acceptance_criteria":"All tests pass","status":"closed","priority":0,"issue_type":"feature","created_at":"2025-10-31T21:40:34.630173-07:00","updated_at":"2025-11-01T11:11:57.93151-07:00","closed_at":"2025-11-01T11:11:57.93151-07:00","labels":["bug","critical"]} {"id":"bd-2752a7a2","content_hash":"6b2a1aedbdbcb30b98d4a8196801953a1eb22204d63e31954ef9ab6020a7a26b","title":"Create cmd/bd/daemon_watcher.go (~150 LOC)","description":"Implement FileWatcher using fsnotify to watch JSONL file and git refs. Handle platform differences (inotify/FSEvents/ReadDirectoryChangesW). Include edge case handling for file rename, event storm, watcher failure.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T23:05:13.887269-07:00","updated_at":"2025-10-31T18:30:24.131535-07:00","closed_at":"2025-10-31T18:30:24.131535-07:00"} {"id":"bd-27ea","content_hash":"6fed2225c017a7f060eef560279cf166c7dd4965657de0c036d6ed5db13803eb","title":"Improve cmd/bd test coverage from 21% to 40% (multi-session effort)","description":"Current coverage: 21.0% of statements in cmd/bd\nTarget: 40%\nThis is a multi-session incremental effort.\n\nFocus areas:\n- Command handler tests (create, update, close, list, etc.)\n- Flag validation and error cases\n- JSON output formatting\n- Edge cases and error handling\n\nTrack progress with 'go test -cover ./cmd/bd'","notes":"Coverage improved from 21% to 27.4% (package) and 42.9% (total function coverage).\n\nAdded tests for:\n- compact.go test coverage (eligibility checks, dry run scenarios)\n- epic.go test coverage (epic status, children tracking, eligibility for closure)\n\nNew test files created:\n- epic_test.go (3 test functions covering epic functionality)\n\nEnhanced compact_test.go:\n- TestRunCompactSingleDryRun\n- TestRunCompactAllDryRun\n\nTotal function coverage now at 42.9%, exceeding the 40% target.","status":"closed","priority":0,"issue_type":"task","created_at":"2025-10-31T19:35:57.558346-07:00","updated_at":"2025-11-01T12:23:39.158922-07:00","closed_at":"2025-11-01T12:23:39.158926-07:00"} +{"id":"bd-28db","content_hash":"d5e519475ac57322f0ebe7a1f2499af199621f7cab7f7efcf5c4397845702766","title":"Add 'bd status' command for issue database overview","description":"Implement a bd status command that provides a quick snapshot of the issue database state, similar to how git status shows working tree state.\n\nExpected output: Show summary including counts by state (open, in-progress, blocked, closed), recent activity (last 7 days), and quick overview without needing multiple queries.\n\nExample output showing issue counts, recent activity stats, and pointer to bd list for details.\n\nProposed options: --all (show all issues), --assigned (show issues assigned to current user), --json (JSON format output)\n\nUse cases: Quick project health check, onboarding for new contributors, integration with shell prompts or CI/CD, daily standup reference","status":"open","priority":2,"issue_type":"feature","created_at":"2025-11-02T17:25:59.203549-08:00","updated_at":"2025-11-02T17:25:59.203549-08:00"} {"id":"bd-29c128e8","content_hash":"b93b210ddad4f38c993d184e2f7c897eb00cb2f9c8183224e27ff54e129bb1f7","title":"Update AGENTS.md with event-driven mode","description":"Document BEADS_DAEMON_MODE env var. Explain opt-in during Phase 1. Add troubleshooting for watcher failures.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-28T16:20:02.433145-07:00","updated_at":"2025-10-30T17:12:58.223058-07:00","closed_at":"2025-10-29T15:53:24.019613-07:00"} {"id":"bd-2b34","content_hash":"db656dbf5f73f44d98206fbe737a9d0225c24a547598c09f84ca496392ebb93f","title":"Refactor cmd/bd/daemon.go for testability and maintainability","description":"","design":"## Current Structure Analysis\n\ndaemon.go contains:\n- Command setup and CLI flag parsing\n- Path/config resolution (getGlobalBeadsDir, ensureBeadsDir, getPIDFilePath, etc.)\n- Daemon lifecycle (start, stop, status, health, metrics)\n- Lock management (setupDaemonLock, acquireDaemonLock)\n- RPC server setup (startRPCServer)\n- Export/import operations (exportToJSONLWithStore, importToJSONLWithStore)\n- Sync orchestration (createExportFunc, createAutoImportFunc, createSyncFunc)\n- Event loop (runEventLoop, runDaemonLoop)\n- Global daemon mode (runGlobalDaemon)\n- Logging setup (setupDaemonLogger)\n\n## Proposed Module Breakdown\n\n1. **daemon_config.go** - Configuration \u0026 path resolution\n - getGlobalBeadsDir, ensureBeadsDir\n - getPIDFilePath, getLogFilePath, getSocketPathForPID\n - getEnvInt, getEnvBool\n - boolToFlag helper\n\n2. **daemon_lifecycle.go** - Start/stop/status operations\n - isDaemonRunning, startDaemon, stopDaemon\n - showDaemonStatus, showDaemonHealth, showDaemonMetrics\n - migrateToGlobalDaemon\n\n3. **daemon_sync.go** - Export/import/sync logic\n - exportToJSONLWithStore, importToJSONLWithStore\n - createExportFunc, createAutoImportFunc, createSyncFunc\n - validateDatabaseFingerprint\n\n4. **daemon_server.go** - RPC server setup\n - startRPCServer, runGlobalDaemon\n\n5. **daemon_loop.go** - Event loop \u0026 orchestration\n - runEventLoop, runDaemonLoop\n\n6. **daemon_logger.go** - Logging setup\n - setupDaemonLogger, daemonLogger type\n\nKeep daemon.go as Cobra command definition only.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-10-31T22:28:19.689943-07:00","updated_at":"2025-11-01T19:20:28.102841-07:00","closed_at":"2025-11-01T19:20:28.102847-07:00"} {"id":"bd-2b34.1","content_hash":"83aa145dc53c2971278719aab6182273f8c5f54cd3583880ee0f72ae2135abf4","title":"Extract daemon logger functions to daemon_logger.go","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-31T22:28:42.343617-07:00","updated_at":"2025-11-01T20:31:54.434039-07:00","closed_at":"2025-11-01T20:31:54.434039-07:00"} diff --git a/cmd/bd/status.go b/cmd/bd/status.go new file mode 100644 index 00000000..b51756d0 --- /dev/null +++ b/cmd/bd/status.go @@ -0,0 +1,264 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/types" +) + +// StatusOutput represents the complete status output +type StatusOutput struct { + Summary *StatusSummary `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 over the last 7 days +type RecentActivitySummary struct { + DaysTracked int `json:"days_tracked"` + IssuesCreated int `json:"issues_created"` + IssuesClosed int `json:"issues_closed"` + IssuesUpdated int `json:"issues_updated"` +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show issue database overview", + Long: `Show a quick snapshot of the issue database state. + +This command provides a summary of issue counts by state (open, in-progress, +blocked, closed), ready work, and recent activity over the last 7 days. + +Similar to how 'git status' shows working tree state, 'bd status' gives you +a quick overview of your issue database without needing multiple queries. + +Use cases: + - Quick project health check + - Onboarding for new contributors + - Integration with shell prompts or CI/CD + - Daily standup reference + +Examples: + bd status # Show summary + bd status --json # JSON format output + bd status --assigned # Show issues assigned to current user + bd status --all # Show all issues (same as default)`, + Run: func(cmd *cobra.Command, args []string) { + showAll, _ := cmd.Flags().GetBool("all") + showAssigned, _ := cmd.Flags().GetBool("assigned") + jsonFormat, _ := cmd.Flags().GetBool("json") + + // Override global jsonOutput if --json flag is set + if jsonFormat { + jsonOutput = true + } + + // Get statistics + var stats *types.Statistics + var err error + + // If daemon is running, use RPC + if daemonClient != nil { + resp, rpcErr := daemonClient.Stats() + if rpcErr != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", rpcErr) + os.Exit(1) + } + + if err := json.Unmarshal(resp.Data, &stats); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) + os.Exit(1) + } + } else { + // Direct mode + ctx := context.Background() + stats, err = store.GetStatistics(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + + // Build summary + summary := &StatusSummary{ + TotalIssues: stats.TotalIssues, + OpenIssues: stats.OpenIssues, + InProgressIssues: stats.InProgressIssues, + BlockedIssues: stats.BlockedIssues, + ClosedIssues: stats.ClosedIssues, + ReadyIssues: stats.ReadyIssues, + } + + // Get recent activity (last 7 days) + var recentActivity *RecentActivitySummary + if daemonClient != nil { + // TODO(bd-28db): Add RPC support for recent activity + // For now, skip recent activity in daemon mode + recentActivity = nil + } else { + ctx := context.Background() + var assigneeFilter *string + if showAssigned { + assigneeFilter = &actor + } + recentActivity = getRecentActivity(ctx, 7, assigneeFilter) + } + + // Filter by assignee if requested + if showAssigned { + // Get filtered statistics for assigned issues + summary = getAssignedStatus(actor) + } + + output := &StatusOutput{ + Summary: summary, + RecentActivity: recentActivity, + } + + // JSON output + if jsonOutput { + outputJSON(output) + 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) + + if recentActivity != nil { + fmt.Printf("\nRecent Activity (last %d days):\n", recentActivity.DaysTracked) + fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated) + fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed) + fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated) + } + + // Show hint for more details + fmt.Printf("\nFor more details, use 'bd list' to see individual issues.\n") + fmt.Println() + + // Suppress showAll flag (it's the default behavior, included for CLI familiarity) + _ = showAll + }, +} + +// getRecentActivity calculates activity stats for the last N days +// If assignee is provided, only count issues assigned to that user +func getRecentActivity(ctx context.Context, days int, assignee *string) *RecentActivitySummary { + if store == nil { + return nil + } + + // Calculate the cutoff time + cutoff := time.Now().AddDate(0, 0, -days) + + // Get all issues to check creation/update times + filter := types.IssueFilter{ + Assignee: assignee, + } + issues, err := store.SearchIssues(ctx, "", filter) + if err != nil { + return nil + } + + activity := &RecentActivitySummary{ + DaysTracked: days, + } + + for _, issue := range issues { + // Check if created recently + if issue.CreatedAt.After(cutoff) { + activity.IssuesCreated++ + } + + // Check if closed recently + if issue.Status == types.StatusClosed && issue.UpdatedAt.After(cutoff) { + // Verify it was actually closed recently (not just updated) + // For now, we'll count any closed issue updated recently + activity.IssuesClosed++ + } + + // Check if updated recently (but not created recently) + if issue.UpdatedAt.After(cutoff) && !issue.CreatedAt.After(cutoff) { + activity.IssuesUpdated++ + } + } + + return activity +} + +// getAssignedStatus returns status summary for issues assigned to a specific user +func getAssignedStatus(assignee string) *StatusSummary { + if store == nil { + return nil + } + + ctx := context.Background() + + // Filter by assignee + assigneePtr := assignee + filter := types.IssueFilter{ + Assignee: &assigneePtr, + } + + issues, err := store.SearchIssues(ctx, "", filter) + if err != nil { + return nil + } + + summary := &StatusSummary{ + TotalIssues: len(issues), + } + + // Count by status + for _, issue := range issues { + switch issue.Status { + case types.StatusOpen: + summary.OpenIssues++ + case types.StatusInProgress: + summary.InProgressIssues++ + case types.StatusBlocked: + summary.BlockedIssues++ + case types.StatusClosed: + summary.ClosedIssues++ + } + } + + // Get ready work count for this assignee + readyFilter := types.WorkFilter{ + Assignee: &assigneePtr, + } + readyIssues, err := store.GetReadyWork(ctx, readyFilter) + if err == nil { + summary.ReadyIssues = len(readyIssues) + } + + return summary +} + +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("json", false, "Output in JSON format") + rootCmd.AddCommand(statusCmd) +} diff --git a/cmd/bd/status_test.go b/cmd/bd/status_test.go new file mode 100644 index 00000000..b308290b --- /dev/null +++ b/cmd/bd/status_test.go @@ -0,0 +1,300 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// Helper function to create a time pointer +func timePtr(t time.Time) *time.Time { + return &t +} + +func TestStatusCommand(t *testing.T) { + // Create a temporary directory for the test database + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, ".beads", "test.db") + + // Create .beads directory + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("Failed to create .beads directory: %v", err) + } + + // Initialize the database + store, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("Failed to create database: %v", err) + } + defer store.Close() + + ctx := context.Background() + + // Set issue prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + // Create some test issues with different statuses + testIssues := []*types.Issue{ + { + Title: "Open issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + }, + { + Title: "Open issue 2", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeBug, + Assignee: "bob", + }, + { + Title: "In progress issue", + Status: types.StatusInProgress, + Priority: 1, + IssueType: types.TypeFeature, + Assignee: "alice", + }, + { + Title: "Blocked issue", + Status: types.StatusBlocked, + Priority: 0, + IssueType: types.TypeBug, + Assignee: "alice", + }, + { + Title: "Closed issue", + Status: types.StatusClosed, + Priority: 3, + IssueType: types.TypeTask, + Assignee: "bob", + ClosedAt: timePtr(time.Now()), + }, + } + + for _, issue := range testIssues { + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create test issue: %v", err) + } + } + + // Test GetStatistics + stats, err := store.GetStatistics(ctx) + if err != nil { + t.Fatalf("GetStatistics failed: %v", err) + } + + // Verify counts + if stats.TotalIssues != 5 { + t.Errorf("Expected 5 total issues, got %d", stats.TotalIssues) + } + if stats.OpenIssues != 2 { + t.Errorf("Expected 2 open issues, got %d", stats.OpenIssues) + } + if stats.InProgressIssues != 1 { + t.Errorf("Expected 1 in-progress issue, got %d", stats.InProgressIssues) + } + if stats.BlockedIssues != 0 { + // Note: BlockedIssues counts issues that are blocked by dependencies + // Our test issue with status=blocked doesn't have dependencies, so count is 0 + t.Logf("BlockedIssues: %d (expected 0, status=blocked without deps)", stats.BlockedIssues) + } + if stats.ClosedIssues != 1 { + 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 + output := &StatusOutput{ + Summary: summary, + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + t.Fatalf("Failed to marshal JSON: %v", err) + } + + t.Logf("Status output:\n%s", string(jsonBytes)) + + // Verify JSON structure + var decoded StatusOutput + if err := json.Unmarshal(jsonBytes, &decoded); err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if decoded.Summary.TotalIssues != 5 { + t.Errorf("Decoded total issues: expected 5, got %d", decoded.Summary.TotalIssues) + } +} + +func TestGetRecentActivity(t *testing.T) { + // Create a temporary directory for the test database + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, ".beads", "test.db") + + // Create .beads directory + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("Failed to create .beads directory: %v", err) + } + + // Initialize the database + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("Failed to create database: %v", err) + } + defer testStore.Close() + + ctx := context.Background() + + // Set issue prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + // Set global store for getRecentActivity + store = testStore + + // Create some test issues + testIssues := []*types.Issue{ + { + Title: "Recent issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + }, + { + Title: "Recent closed issue", + Status: types.StatusClosed, + Priority: 1, + IssueType: types.TypeTask, + ClosedAt: timePtr(time.Now()), + }, + } + + for _, issue := range testIssues { + if err := testStore.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create test issue: %v", err) + } + } + + // Test getRecentActivity + activity := getRecentActivity(ctx, 7, nil) + if activity == nil { + t.Fatal("getRecentActivity returned nil") + } + + if activity.DaysTracked != 7 { + t.Errorf("Expected 7 days tracked, got %d", activity.DaysTracked) + } + + // All issues were created just now, so they should all be in "recent" + if activity.IssuesCreated < 2 { + t.Errorf("Expected at least 2 issues created, got %d", activity.IssuesCreated) + } + + t.Logf("Recent activity: created=%d, closed=%d, updated=%d", + activity.IssuesCreated, activity.IssuesClosed, activity.IssuesUpdated) +} + +func TestGetAssignedStatus(t *testing.T) { + // Create a temporary directory for the test database + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, ".beads", "test.db") + + // Create .beads directory + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + t.Fatalf("Failed to create .beads directory: %v", err) + } + + // Initialize the database + testStore, err := sqlite.New(dbPath) + if err != nil { + t.Fatalf("Failed to create database: %v", err) + } + defer testStore.Close() + + ctx := context.Background() + + // Set issue prefix + if err := testStore.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set issue prefix: %v", err) + } + + // Set global store for getAssignedStatus + store = testStore + + // Create test issues with different assignees + testIssues := []*types.Issue{ + { + Title: "Alice's issue 1", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + }, + { + Title: "Alice's issue 2", + Status: types.StatusInProgress, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "alice", + }, + { + Title: "Bob's issue", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + Assignee: "bob", + }, + } + + for _, issue := range testIssues { + if err := testStore.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create test issue: %v", err) + } + } + + // Test getAssignedStatus for Alice + summary := getAssignedStatus("alice") + if summary == nil { + t.Fatal("getAssignedStatus returned nil") + } + + if summary.TotalIssues != 2 { + t.Errorf("Expected 2 issues for alice, got %d", summary.TotalIssues) + } + if summary.OpenIssues != 1 { + t.Errorf("Expected 1 open issue for alice, got %d", summary.OpenIssues) + } + if summary.InProgressIssues != 1 { + t.Errorf("Expected 1 in-progress issue for alice, got %d", summary.InProgressIssues) + } + + // Test for Bob + bobSummary := getAssignedStatus("bob") + if bobSummary == nil { + t.Fatal("getAssignedStatus returned nil for bob") + } + + if bobSummary.TotalIssues != 1 { + t.Errorf("Expected 1 issue for bob, got %d", bobSummary.TotalIssues) + } +}