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 <ox@sageox.ai>

Executed-By: beads/crew/emma
Rig: beads
Role: crew
This commit is contained in:
Ryan
2026-01-25 16:56:33 -08:00
committed by GitHub
parent 1423bdc5fb
commit 27d6a8c2da
3 changed files with 97 additions and 17 deletions

View File

@@ -15,9 +15,6 @@ import (
"github.com/steveyegge/beads/internal/types" "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 // CleanupResult contains the results of a cleanup operation
type CleanupResult struct { type CleanupResult struct {
DeletedCount int DeletedCount int
@@ -27,6 +24,9 @@ type CleanupResult struct {
// StaleClosedIssues converts stale closed issues to tombstones. // StaleClosedIssues converts stale closed issues to tombstones.
// This is the fix handler for the "Stale Closed Issues" doctor check. // 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 { func StaleClosedIssues(path string) error {
if err := validateBeadsWorkspace(path); err != nil { if err := validateBeadsWorkspace(path); err != nil {
return err return err
@@ -34,9 +34,26 @@ func StaleClosedIssues(path string) error {
beadsDir := filepath.Join(path, ".beads") 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 // Get database path
var dbPath string var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { if cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir) dbPath = cfg.DatabasePath(beadsDir)
} else { } else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
@@ -54,8 +71,8 @@ func StaleClosedIssues(path string) error {
} }
defer func() { _ = store.Close() }() defer func() { _ = store.Close() }()
// Find closed issues older than threshold // Find closed issues older than configured threshold
cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays) cutoff := time.Now().AddDate(0, 0, -thresholdDays)
statusClosed := types.StatusClosed statusClosed := types.StatusClosed
filter := types.IssueFilter{ filter := types.IssueFilter{
Status: &statusClosed, Status: &statusClosed,
@@ -86,7 +103,7 @@ func StaleClosedIssues(path string) error {
fmt.Println(" No stale closed issues to clean up") fmt.Println(" No stale closed issues to clean up")
} else { } else {
if deleted > 0 { 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 { if skipped > 0 {
fmt.Printf(" Skipped %d pinned issue(s)\n", skipped) fmt.Printf(" Skipped %d pinned issue(s)\n", skipped)

View File

@@ -15,18 +15,44 @@ import (
"github.com/steveyegge/beads/internal/types" "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. // 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 { func CheckStaleClosedIssues(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory // Follow redirect to resolve actual beads directory
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) 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 var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { if cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir) dbPath = cfg.DatabasePath(beadsDir)
} else { } else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
@@ -53,8 +79,32 @@ func CheckStaleClosedIssues(path string) DoctorCheck {
} }
defer func() { _ = store.Close() }() defer func() { _ = store.Close() }()
// Find closed issues older than threshold // If disabled (0), check for large closed issue count and warn if appropriate
cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays) 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 statusClosed := types.StatusClosed
filter := types.IssueFilter{ filter := types.IssueFilter{
Status: &statusClosed, Status: &statusClosed,
@@ -91,7 +141,7 @@ func CheckStaleClosedIssues(path string) DoctorCheck {
return DoctorCheck{ return DoctorCheck{
Name: "Stale Closed Issues", Name: "Stale Closed Issues",
Status: StatusWarning, 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", 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", Fix: "Run 'bd doctor --fix' to cleanup, or 'bd admin cleanup --force' for more options",
Category: CategoryMaintenance, Category: CategoryMaintenance,

View File

@@ -18,7 +18,7 @@ type Config struct {
// Deletions configuration // Deletions configuration
DeletionsRetentionDays int `json:"deletions_retention_days,omitempty"` // 0 means use default (3 days) 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. // When Mode is "server", connects to external dolt sql-server instead of embedded.
// This enables multi-writer access for multi-agent environments. // This enables multi-writer access for multi-agent environments.
DoltMode string `json:"dolt_mode,omitempty"` // "embedded" (default) or "server" 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) DoltServerUser string `json:"dolt_server_user,omitempty"` // MySQL user (default: root)
// Note: Password should be set via BEADS_DOLT_PASSWORD env var for security // 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. // Deprecated: LastBdVersion is no longer used for version tracking.
// Version is now stored in .local_version (gitignored) to prevent // Version is now stored in .local_version (gitignored) to prevent
// upgrade notifications firing after git operations reset metadata.json. // upgrade notifications firing after git operations reset metadata.json.
@@ -152,6 +156,15 @@ func (c *Config) GetDeletionsRetentionDays() int {
return c.DeletionsRetentionDays 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 // Backend constants
const ( const (
BackendSQLite = "sqlite" BackendSQLite = "sqlite"