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