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)