Files
beads/cmd/bd/ready.go
Steve Yegge 9088988edd Improve staleness check error handling and optimization (bd-n4td, bd-o4qy, bd-c4rq)
This commit implements three related improvements to database staleness checking:

**bd-n4td (P2): Add warning when staleness check errors**
- Added stderr warnings when CheckStaleness fails in ensureDatabaseFresh
- Users now see "Warning: could not check database staleness: <error>"
- Provides visibility into staleness check failures while allowing operations to proceed

**bd-o4qy (P2): Improve CheckStaleness error handling**
- Updated CheckStaleness to distinguish between expected and abnormal conditions:
  * Returns (false, nil) for expected "no data yet" scenarios (missing metadata, missing JSONL)
  * Returns (false, err) for abnormal errors (glob failures, permission errors)
- Updated RPC server (2 locations) to log staleness errors but allow requests to proceed
- Prevents blocking operations due to transient staleness check issues
- Added comprehensive function documentation

**bd-c4rq (P3): Refactor staleness check to avoid function call overhead**
- Moved daemon check from inside ensureDatabaseFresh to all 8 call sites
- Avoids unnecessary function call when running in daemon mode
- Updated: list.go, info.go, status.go, show.go, stale.go, duplicates.go, ready.go, validate.go
- Extracted staleness functions to new staleness.go for better organization

**Code review fixes:**
- Removed dead code in CheckStaleness (unreachable jsonlPath == "" check)
- Removed unused ensureDatabaseFreshQuiet function

**Files changed:**
- New: cmd/bd/staleness.go (extracted staleness checking functions)
- Modified: 8 command files (added daemon check before staleness calls)
- Modified: internal/autoimport/autoimport.go (improved error handling)
- Modified: internal/rpc/server_export_import_auto.go (handle errors gracefully)
2025-11-20 20:45:39 -05:00

292 lines
9.4 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/util"
)
var readyCmd = &cobra.Command{
Use: "ready",
Short: "Show ready work (no blockers, open or in-progress)",
Run: func(cmd *cobra.Command, args []string) {
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
sortPolicy, _ := cmd.Flags().GetString("sort")
labels, _ := cmd.Flags().GetStringSlice("label")
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// Normalize labels: trim, dedupe, remove empty
labels = util.NormalizeLabels(labels)
labelsAny = util.NormalizeLabels(labelsAny)
filter := types.WorkFilter{
// Leave Status empty to get both 'open' and 'in_progress' (bd-165)
Limit: limit,
SortPolicy: types.SortPolicy(sortPolicy),
Labels: labels,
LabelsAny: labelsAny,
}
// Use Changed() to properly handle P0 (priority=0)
if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority")
filter.Priority = &priority
}
if assignee != "" {
filter.Assignee = &assignee
}
// Validate sort policy
if !filter.SortPolicy.IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid sort policy '%s'. Valid values: hybrid, priority, oldest\n", sortPolicy)
os.Exit(1)
}
// If daemon is running, use RPC
if daemonClient != nil {
readyArgs := &rpc.ReadyArgs{
Assignee: assignee,
Limit: limit,
SortPolicy: sortPolicy,
Labels: labels,
LabelsAny: labelsAny,
}
if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority")
readyArgs.Priority = &priority
}
resp, err := daemonClient.Ready(readyArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
if jsonOutput {
if issues == nil {
issues = []*types.Issue{}
}
outputJSON(issues)
return
}
if len(issues) == 0 {
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
yellow("✨"))
return
}
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues))
for i, issue := range issues {
fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
if issue.EstimatedMinutes != nil {
fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes)
}
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
}
fmt.Println()
return
}
// Direct mode
ctx := context.Background()
// Check database freshness before reading (bd-2q6d, bd-c4rq)
// Skip check when using daemon (daemon auto-imports on staleness)
if daemonClient == nil {
if err := ensureDatabaseFresh(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
issues, err := store.GetReadyWork(ctx, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// If no ready work found, check if git has issues and auto-import
if len(issues) == 0 {
if checkAndAutoImport(ctx, store) {
// Re-run the query after import
issues, err = store.GetReadyWork(ctx, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}
if jsonOutput {
// Always output array, even if empty
if issues == nil {
issues = []*types.Issue{}
}
outputJSON(issues)
return
}
if len(issues) == 0 {
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
yellow("✨"))
return
}
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues))
for i, issue := range issues {
fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
if issue.EstimatedMinutes != nil {
fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes)
}
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
}
fmt.Println()
},
}
var blockedCmd = &cobra.Command{
Use: "blocked",
Short: "Show blocked issues",
Run: func(cmd *cobra.Command, args []string) {
// 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 daemonClient != nil && store == nil {
var err error
store, err = sqlite.New(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
defer func() { _ = store.Close() }()
}
ctx := context.Background()
blocked, err := store.GetBlockedIssues(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// Always output array, even if empty
if blocked == nil {
blocked = []*types.BlockedIssue{}
}
outputJSON(blocked)
return
}
if len(blocked) == 0 {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s No blocked issues\n\n", green("✨"))
return
}
red := color.New(color.FgRed).SprintFunc()
fmt.Printf("\n%s Blocked issues (%d):\n\n", red("🚫"), len(blocked))
for _, issue := range blocked {
fmt.Printf("[P%d] %s: %s\n", issue.Priority, issue.ID, issue.Title)
blockedBy := issue.BlockedBy
if blockedBy == nil {
blockedBy = []string{}
}
fmt.Printf(" Blocked by %d open dependencies: %v\n",
issue.BlockedByCount, blockedBy)
fmt.Println()
}
},
}
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.AverageLeadTime > 0 {
fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
fmt.Println()
return
}
// Direct mode
ctx := context.Background()
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.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")
readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest")
readyCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
readyCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd)
rootCmd.AddCommand(statsCmd)
}