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"
)
// 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)

View File

@@ -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,