From 27d6a8c2da09c35668109ea70702e0f92cc13bcb Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Sun, 25 Jan 2026 16:56:33 -0800 Subject: [PATCH] feat(doctor): make stale closed issues check configurable (#1291) Add stale_closed_issues_days config option to metadata.json: - 0 (default): Check disabled - repos keep unlimited beads - >0: Enable check with specified day threshold Design philosophy: Time-based cleanup is a crude proxy for the real concern (database size). Disabled by default since a repo with 100 closed issues from 5 years ago doesn't need cleanup. Also adds a warning when check is disabled but database has >10,000 closed issues, recommending users enable the threshold. Co-Authored-By: SageOx Executed-By: beads/crew/emma Rig: beads Role: crew --- cmd/bd/doctor/fix/maintenance.go | 31 ++++++++++---- cmd/bd/doctor/maintenance.go | 68 +++++++++++++++++++++++++++---- internal/configfile/configfile.go | 15 ++++++- 3 files changed, 97 insertions(+), 17 deletions(-) diff --git a/cmd/bd/doctor/fix/maintenance.go b/cmd/bd/doctor/fix/maintenance.go index 89c24efe..04d94a1a 100644 --- a/cmd/bd/doctor/fix/maintenance.go +++ b/cmd/bd/doctor/fix/maintenance.go @@ -15,9 +15,6 @@ import ( "github.com/steveyegge/beads/internal/types" ) -// DefaultCleanupAgeDays is the default age threshold for cleanup -const DefaultCleanupAgeDays = 30 - // CleanupResult contains the results of a cleanup operation type CleanupResult struct { DeletedCount int @@ -27,6 +24,9 @@ type CleanupResult struct { // StaleClosedIssues converts stale closed issues to tombstones. // This is the fix handler for the "Stale Closed Issues" doctor check. +// +// This fix is DISABLED by default (stale_closed_issues_days=0). Users must +// explicitly set a positive threshold in metadata.json to enable cleanup. func StaleClosedIssues(path string) error { if err := validateBeadsWorkspace(path); err != nil { return err @@ -34,9 +34,26 @@ func StaleClosedIssues(path string) error { beadsDir := filepath.Join(path, ".beads") + // Load config and check if cleanup is enabled + cfg, err := configfile.Load(beadsDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Get threshold; 0 means disabled + var thresholdDays int + if cfg != nil { + thresholdDays = cfg.GetStaleClosedIssuesDays() + } + + if thresholdDays == 0 { + fmt.Println(" Stale closed issues cleanup disabled (set stale_closed_issues_days to enable)") + return nil + } + // Get database path var dbPath string - if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + if cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) @@ -54,8 +71,8 @@ func StaleClosedIssues(path string) error { } defer func() { _ = store.Close() }() - // Find closed issues older than threshold - cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays) + // Find closed issues older than configured threshold + cutoff := time.Now().AddDate(0, 0, -thresholdDays) statusClosed := types.StatusClosed filter := types.IssueFilter{ Status: &statusClosed, @@ -86,7 +103,7 @@ func StaleClosedIssues(path string) error { fmt.Println(" No stale closed issues to clean up") } else { if deleted > 0 { - fmt.Printf(" Cleaned up %d stale closed issue(s)\n", deleted) + fmt.Printf(" Cleaned up %d stale closed issue(s) (older than %d days)\n", deleted, thresholdDays) } if skipped > 0 { fmt.Printf(" Skipped %d pinned issue(s)\n", skipped) diff --git a/cmd/bd/doctor/maintenance.go b/cmd/bd/doctor/maintenance.go index 43f87f28..89508d3a 100644 --- a/cmd/bd/doctor/maintenance.go +++ b/cmd/bd/doctor/maintenance.go @@ -15,18 +15,44 @@ import ( "github.com/steveyegge/beads/internal/types" ) -// DefaultCleanupAgeDays is the default age threshold for cleanup suggestions -const DefaultCleanupAgeDays = 30 - // CheckStaleClosedIssues detects closed issues that could be cleaned up. -// This consolidates the cleanup command into doctor checks. +// +// Design note: Time-based thresholds are a crude proxy for the real concern, +// which is database size. A repo with 100 closed issues from 5 years ago +// doesn't need cleanup, while 50,000 issues from yesterday might. +// The actual threshold should be based on acceptable maximum database size. +// +// This check is DISABLED by default (stale_closed_issues_days=0). Users who +// want time-based pruning must explicitly enable it in metadata.json. +// Future: Consider adding max_database_size_mb for size-based thresholds. + +// largeClosedIssuesThreshold triggers a warning to enable stale cleanup +const largeClosedIssuesThreshold = 10000 + func CheckStaleClosedIssues(path string) DoctorCheck { // Follow redirect to resolve actual beads directory beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) - // Check metadata.json first for custom database name + // Load config and check if this check is enabled + cfg, err := configfile.Load(beadsDir) + if err != nil { + return DoctorCheck{ + Name: "Stale Closed Issues", + Status: StatusOK, + Message: "N/A (config error)", + Category: CategoryMaintenance, + } + } + + // If config is nil, use defaults (check disabled) + var thresholdDays int + if cfg != nil { + thresholdDays = cfg.GetStaleClosedIssuesDays() + } + + // Get database path var dbPath string - if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { + if cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) @@ -53,8 +79,32 @@ func CheckStaleClosedIssues(path string) DoctorCheck { } defer func() { _ = store.Close() }() - // Find closed issues older than threshold - cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays) + // If disabled (0), check for large closed issue count and warn if appropriate + if thresholdDays == 0 { + statusClosed := types.StatusClosed + filter := types.IssueFilter{Status: &statusClosed} + issues, err := store.SearchIssues(ctx, "", filter) + if err != nil || len(issues) < largeClosedIssuesThreshold { + return DoctorCheck{ + Name: "Stale Closed Issues", + Status: StatusOK, + Message: "Disabled (set stale_closed_issues_days to enable)", + Category: CategoryMaintenance, + } + } + // Large number of closed issues - recommend enabling cleanup + return DoctorCheck{ + Name: "Stale Closed Issues", + Status: StatusWarning, + Message: fmt.Sprintf("Disabled but %d closed issues exist", len(issues)), + Detail: "Consider enabling stale_closed_issues_days to manage database size", + Fix: "Add \"stale_closed_issues_days\": 30 to .beads/metadata.json", + Category: CategoryMaintenance, + } + } + + // Find closed issues older than configured threshold + cutoff := time.Now().AddDate(0, 0, -thresholdDays) statusClosed := types.StatusClosed filter := types.IssueFilter{ Status: &statusClosed, @@ -91,7 +141,7 @@ func CheckStaleClosedIssues(path string) DoctorCheck { return DoctorCheck{ Name: "Stale Closed Issues", Status: StatusWarning, - Message: fmt.Sprintf("%d closed issue(s) older than %d days", cleanable, DefaultCleanupAgeDays), + Message: fmt.Sprintf("%d closed issue(s) older than %d days", cleanable, thresholdDays), Detail: "These issues can be cleaned up to reduce database size", Fix: "Run 'bd doctor --fix' to cleanup, or 'bd admin cleanup --force' for more options", Category: CategoryMaintenance, diff --git a/internal/configfile/configfile.go b/internal/configfile/configfile.go index 99bea938..d53afbc6 100644 --- a/internal/configfile/configfile.go +++ b/internal/configfile/configfile.go @@ -18,7 +18,7 @@ type Config struct { // Deletions configuration DeletionsRetentionDays int `json:"deletions_retention_days,omitempty"` // 0 means use default (3 days) - // Dolt server mode configuration (bd-dolt.2.2) +// Dolt server mode configuration (bd-dolt.2.2) // When Mode is "server", connects to external dolt sql-server instead of embedded. // This enables multi-writer access for multi-agent environments. DoltMode string `json:"dolt_mode,omitempty"` // "embedded" (default) or "server" @@ -27,6 +27,10 @@ type Config struct { DoltServerUser string `json:"dolt_server_user,omitempty"` // MySQL user (default: root) // Note: Password should be set via BEADS_DOLT_PASSWORD env var for security + // Stale closed issues check configuration + // 0 = disabled (default), positive = threshold in days + StaleClosedIssuesDays int `json:"stale_closed_issues_days,omitempty"` + // Deprecated: LastBdVersion is no longer used for version tracking. // Version is now stored in .local_version (gitignored) to prevent // upgrade notifications firing after git operations reset metadata.json. @@ -152,6 +156,15 @@ func (c *Config) GetDeletionsRetentionDays() int { return c.DeletionsRetentionDays } +// GetStaleClosedIssuesDays returns the configured threshold for stale closed issues. +// Returns 0 if disabled (the default), or a positive value if enabled. +func (c *Config) GetStaleClosedIssuesDays() int { + if c.StaleClosedIssuesDays < 0 { + return 0 + } + return c.StaleClosedIssuesDays +} + // Backend constants const ( BackendSQLite = "sqlite"