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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user