feat: consolidate maintenance commands into bd doctor --fix (bd-bqcc)
Add new Maintenance category to bd doctor with checks for: - Stale closed issues (older than 30 days) - Expired tombstones (older than TTL) - Compaction candidates (info only) Add fix handlers for cleanup and tombstone pruning via bd doctor --fix. Add deprecation hints to cleanup, compact, and detect-pollution commands suggesting users try bd doctor instead. This consolidation reduces cognitive load - users just need to remember 'bd doctor' for health checks and 'bd doctor --fix' for maintenance. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,11 @@ type CleanupEmptyResponse struct {
|
|||||||
|
|
||||||
// Hard delete mode: bypass tombstone TTL safety, use --older-than days directly
|
// Hard delete mode: bypass tombstone TTL safety, use --older-than days directly
|
||||||
|
|
||||||
// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX
|
// showCleanupDeprecationHint shows a hint about bd doctor --fix (bd-bqcc)
|
||||||
|
func showCleanupDeprecationHint() {
|
||||||
|
fmt.Fprintln(os.Stderr, ui.RenderMuted("💡 Tip: 'bd doctor --fix' can now cleanup stale issues and prune tombstones"))
|
||||||
|
}
|
||||||
|
|
||||||
var cleanupCmd = &cobra.Command{
|
var cleanupCmd = &cobra.Command{
|
||||||
Use: "cleanup",
|
Use: "cleanup",
|
||||||
GroupID: "maint",
|
GroupID: "maint",
|
||||||
@@ -252,6 +256,11 @@ SEE ALSO:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bd-bqcc: Show hint about doctor --fix consolidation
|
||||||
|
if !jsonOutput {
|
||||||
|
showCleanupDeprecationHint()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/config"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
"github.com/steveyegge/beads/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -109,7 +110,11 @@ type CompactApplyResponse struct {
|
|||||||
TombstonesPruned *TombstonePrunedInfo `json:"tombstones_pruned,omitempty"`
|
TombstonesPruned *TombstonePrunedInfo `json:"tombstones_pruned,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX
|
// showCompactDeprecationHint shows a hint about bd doctor consolidation (bd-bqcc)
|
||||||
|
func showCompactDeprecationHint() {
|
||||||
|
fmt.Fprintln(os.Stderr, ui.RenderMuted("💡 Tip: 'bd doctor' now shows compaction candidates in the Maintenance section"))
|
||||||
|
}
|
||||||
|
|
||||||
var compactCmd = &cobra.Command{
|
var compactCmd = &cobra.Command{
|
||||||
Use: "compact",
|
Use: "compact",
|
||||||
GroupID: "maint",
|
GroupID: "maint",
|
||||||
@@ -578,6 +583,9 @@ func runCompactStats(ctx context.Context, store *sqlite.SQLiteStorage) {
|
|||||||
if tier2Size > 0 {
|
if tier2Size > 0 {
|
||||||
fmt.Printf(" Estimated savings: %d bytes (95%%)\n", tier2Size*95/100)
|
fmt.Printf(" Estimated savings: %d bytes (95%%)\n", tier2Size*95/100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bd-bqcc: Show hint about doctor consolidation
|
||||||
|
showCompactDeprecationHint()
|
||||||
}
|
}
|
||||||
|
|
||||||
func progressBar(current, total int) string {
|
func progressBar(current, total int) string {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/ui"
|
"github.com/steveyegge/beads/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: Consider consolidating into 'bd doctor --fix' for simpler maintenance UX
|
// showDetectPollutionDeprecationHint shows a hint about bd doctor consolidation (bd-bqcc)
|
||||||
|
func showDetectPollutionDeprecationHint() {
|
||||||
|
fmt.Fprintln(os.Stderr, ui.RenderMuted("💡 Tip: 'bd doctor' now detects test pollution in the Metadata section"))
|
||||||
|
}
|
||||||
|
|
||||||
var detectPollutionCmd = &cobra.Command{
|
var detectPollutionCmd = &cobra.Command{
|
||||||
Use: "detect-pollution",
|
Use: "detect-pollution",
|
||||||
GroupID: "maint",
|
GroupID: "maint",
|
||||||
@@ -134,6 +138,8 @@ NOTE: Review detected issues carefully before using --clean. False positives are
|
|||||||
|
|
||||||
if !clean {
|
if !clean {
|
||||||
fmt.Printf("Run 'bd detect-pollution --clean' to delete these issues (with confirmation).\n")
|
fmt.Printf("Run 'bd detect-pollution --clean' to delete these issues (with confirmation).\n")
|
||||||
|
// bd-bqcc: Show hint about doctor consolidation
|
||||||
|
showDetectPollutionDeprecationHint()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -412,6 +412,16 @@ func applyFixList(path string, fixes []doctorCheck) {
|
|||||||
// No auto-fix: git conflicts require manual resolution
|
// No auto-fix: git conflicts require manual resolution
|
||||||
fmt.Printf(" ⚠ Resolve conflicts manually: git checkout --ours or --theirs .beads/issues.jsonl\n")
|
fmt.Printf(" ⚠ Resolve conflicts manually: git checkout --ours or --theirs .beads/issues.jsonl\n")
|
||||||
continue
|
continue
|
||||||
|
case "Stale Closed Issues":
|
||||||
|
// bd-bqcc: consolidate cleanup into doctor --fix
|
||||||
|
err = fix.StaleClosedIssues(path)
|
||||||
|
case "Expired Tombstones":
|
||||||
|
// bd-bqcc: consolidate cleanup into doctor --fix
|
||||||
|
err = fix.ExpiredTombstones(path)
|
||||||
|
case "Compaction Candidates":
|
||||||
|
// No auto-fix: compaction requires agent review
|
||||||
|
fmt.Printf(" ⚠ Run 'bd compact --analyze' to review candidates\n")
|
||||||
|
continue
|
||||||
default:
|
default:
|
||||||
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
|
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
|
||||||
fmt.Printf(" Manual fix: %s\n", check.Fix)
|
fmt.Printf(" Manual fix: %s\n", check.Fix)
|
||||||
@@ -792,6 +802,21 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
result.OverallOK = false
|
result.OverallOK = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check 26: Stale closed issues (maintenance, bd-bqcc)
|
||||||
|
staleClosedCheck := convertDoctorCheck(doctor.CheckStaleClosedIssues(path))
|
||||||
|
result.Checks = append(result.Checks, staleClosedCheck)
|
||||||
|
// Don't fail overall check for stale issues, just warn
|
||||||
|
|
||||||
|
// Check 27: Expired tombstones (maintenance, bd-bqcc)
|
||||||
|
tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path))
|
||||||
|
result.Checks = append(result.Checks, tombstonesExpiredCheck)
|
||||||
|
// Don't fail overall check for expired tombstones, just warn
|
||||||
|
|
||||||
|
// Check 28: Compaction candidates (maintenance, bd-bqcc)
|
||||||
|
compactionCheck := convertDoctorCheck(doctor.CheckCompactionCandidates(path))
|
||||||
|
result.Checks = append(result.Checks, compactionCheck)
|
||||||
|
// Info only, not a warning - compaction requires human review
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
175
cmd/bd/doctor/fix/maintenance.go
Normal file
175
cmd/bd/doctor/fix/maintenance.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package fix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
|
"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
|
||||||
|
TombstoneCount int
|
||||||
|
SkippedPinned int
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleClosedIssues converts stale closed issues to tombstones.
|
||||||
|
// This is the fix handler for the "Stale Closed Issues" doctor check.
|
||||||
|
func StaleClosedIssues(path string) error {
|
||||||
|
if err := validateBeadsWorkspace(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
|
||||||
|
// Get database path
|
||||||
|
var dbPath string
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
} else {
|
||||||
|
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
fmt.Println(" No database found, nothing to clean up")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = store.Close() }()
|
||||||
|
|
||||||
|
// Find closed issues older than threshold
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays)
|
||||||
|
statusClosed := types.StatusClosed
|
||||||
|
filter := types.IssueFilter{
|
||||||
|
Status: &statusClosed,
|
||||||
|
ClosedBefore: &cutoff,
|
||||||
|
}
|
||||||
|
|
||||||
|
issues, err := store.SearchIssues(ctx, "", filter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out pinned issues and delete the rest
|
||||||
|
var deleted, skipped int
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.Pinned {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.DeleteIssue(ctx, issue.ID); err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to delete %s: %v\n", issue.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleted == 0 && skipped == 0 {
|
||||||
|
fmt.Println(" No stale closed issues to clean up")
|
||||||
|
} else {
|
||||||
|
if deleted > 0 {
|
||||||
|
fmt.Printf(" Cleaned up %d stale closed issue(s)\n", deleted)
|
||||||
|
}
|
||||||
|
if skipped > 0 {
|
||||||
|
fmt.Printf(" Skipped %d pinned issue(s)\n", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiredTombstones prunes expired tombstones from issues.jsonl.
|
||||||
|
// This is the fix handler for the "Expired Tombstones" doctor check.
|
||||||
|
func ExpiredTombstones(path string) error {
|
||||||
|
if err := validateBeadsWorkspace(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||||
|
fmt.Println(" No JSONL file found, nothing to prune")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all issues
|
||||||
|
file, err := os.Open(jsonlPath) // #nosec G304 - path constructed safely
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open issues.jsonl: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allIssues []*types.Issue
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
issue.SetDefaults()
|
||||||
|
allIssues = append(allIssues, &issue)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
ttl := types.DefaultTombstoneTTL
|
||||||
|
|
||||||
|
// Filter out expired tombstones
|
||||||
|
var kept []*types.Issue
|
||||||
|
var prunedCount int
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
if issue.IsExpired(ttl) {
|
||||||
|
prunedCount++
|
||||||
|
} else {
|
||||||
|
kept = append(kept, issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if prunedCount == 0 {
|
||||||
|
fmt.Println(" No expired tombstones to prune")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back the pruned file atomically
|
||||||
|
tempFile, err := os.CreateTemp(beadsDir, "issues.jsonl.prune.*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(tempFile)
|
||||||
|
for _, issue := range kept {
|
||||||
|
if err := encoder.Encode(issue); err != nil {
|
||||||
|
tempFile.Close()
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("failed to write issue %s: %w", issue.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tempFile.Close()
|
||||||
|
|
||||||
|
// Atomically replace
|
||||||
|
if err := os.Rename(tempPath, jsonlPath); err != nil {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
return fmt.Errorf("failed to replace issues.jsonl: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ttlDays := int(ttl.Hours() / 24)
|
||||||
|
fmt.Printf(" Pruned %d expired tombstone(s) (older than %d days)\n", prunedCount, ttlDays)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
77
cmd/bd/doctor/fix/maintenance_test.go
Normal file
77
cmd/bd/doctor/fix/maintenance_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package fix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStaleClosedIssues_NoDatabase(t *testing.T) {
|
||||||
|
// Create temp directory with .beads but no database
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should succeed without database
|
||||||
|
err := StaleClosedIssues(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleClosedIssues_NoBeadsDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Should fail without .beads directory
|
||||||
|
err := StaleClosedIssues(tmpDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing .beads directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredTombstones_NoJSONL(t *testing.T) {
|
||||||
|
// Create temp directory with .beads but no JSONL
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should succeed without JSONL
|
||||||
|
err := ExpiredTombstones(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredTombstones_NoBeadsDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Should fail without .beads directory
|
||||||
|
err := ExpiredTombstones(tmpDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing .beads directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredTombstones_EmptyJSONL(t *testing.T) {
|
||||||
|
// Create temp directory with .beads and empty JSONL
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should succeed with empty JSONL
|
||||||
|
err := ExpiredTombstones(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
226
cmd/bd/doctor/maintenance.go
Normal file
226
cmd/bd/doctor/maintenance.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
|
"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.
|
||||||
|
func CheckStaleClosedIssues(path string) DoctorCheck {
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
|
||||||
|
// Check metadata.json first for custom database name
|
||||||
|
var dbPath string
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
} else {
|
||||||
|
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Stale Closed Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (no database)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Stale Closed Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (unable to open database)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() { _ = store.Close() }()
|
||||||
|
|
||||||
|
// Find closed issues older than threshold
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -DefaultCleanupAgeDays)
|
||||||
|
statusClosed := types.StatusClosed
|
||||||
|
filter := types.IssueFilter{
|
||||||
|
Status: &statusClosed,
|
||||||
|
ClosedBefore: &cutoff,
|
||||||
|
}
|
||||||
|
|
||||||
|
issues, err := store.SearchIssues(ctx, "", filter)
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Stale Closed Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (query failed)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out pinned issues
|
||||||
|
var cleanable int
|
||||||
|
for _, issue := range issues {
|
||||||
|
if !issue.Pinned {
|
||||||
|
cleanable++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanable == 0 {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Stale Closed Issues",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No stale closed issues",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Stale Closed Issues",
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: fmt.Sprintf("%d closed issue(s) older than %d days", cleanable, DefaultCleanupAgeDays),
|
||||||
|
Detail: "These issues can be cleaned up to reduce database size",
|
||||||
|
Fix: "Run 'bd doctor --fix' to cleanup, or 'bd cleanup --force' for more options",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckExpiredTombstones detects tombstones that have exceeded their TTL.
|
||||||
|
func CheckExpiredTombstones(path string) DoctorCheck {
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Expired Tombstones",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (no JSONL file)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read JSONL and count expired tombstones
|
||||||
|
file, err := os.Open(jsonlPath) // #nosec G304 - path constructed safely
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Expired Tombstones",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (unable to read JSONL)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var expiredCount int
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
ttl := types.DefaultTombstoneTTL
|
||||||
|
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
issue.SetDefaults()
|
||||||
|
if issue.IsExpired(ttl) {
|
||||||
|
expiredCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiredCount == 0 {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Expired Tombstones",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No expired tombstones",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ttlDays := int(ttl.Hours() / 24)
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Expired Tombstones",
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: fmt.Sprintf("%d tombstone(s) older than %d days", expiredCount, ttlDays),
|
||||||
|
Detail: "Expired tombstones can be pruned to reduce JSONL file size",
|
||||||
|
Fix: "Run 'bd doctor --fix' to prune, or 'bd cleanup --force' for more options",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCompactionCandidates detects issues eligible for compaction.
|
||||||
|
func CheckCompactionCandidates(path string) DoctorCheck {
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
|
||||||
|
// Check metadata.json first for custom database name
|
||||||
|
var dbPath string
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
} else {
|
||||||
|
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Compaction Candidates",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (no database)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Compaction Candidates",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (unable to open database)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() { _ = store.Close() }()
|
||||||
|
|
||||||
|
tier1, err := store.GetTier1Candidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Compaction Candidates",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (query failed)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tier1) == 0 {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Compaction Candidates",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No compaction candidates",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
var totalSize int
|
||||||
|
for _, c := range tier1 {
|
||||||
|
totalSize += c.OriginalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Compaction Candidates",
|
||||||
|
Status: StatusOK, // Info only, not a warning
|
||||||
|
Message: fmt.Sprintf("%d issue(s) eligible for compaction (%d bytes)", len(tier1), totalSize),
|
||||||
|
Detail: "Compaction requires agent review; not auto-fixable",
|
||||||
|
Fix: "Run 'bd compact --analyze' to review candidates",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
93
cmd/bd/doctor/maintenance_test.go
Normal file
93
cmd/bd/doctor/maintenance_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckStaleClosedIssues_NoDatabase(t *testing.T) {
|
||||||
|
// Create temp directory with .beads but no database
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := CheckStaleClosedIssues(tmpDir)
|
||||||
|
|
||||||
|
if check.Name != "Stale Closed Issues" {
|
||||||
|
t.Errorf("expected name 'Stale Closed Issues', got %q", check.Name)
|
||||||
|
}
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("expected status OK, got %q", check.Status)
|
||||||
|
}
|
||||||
|
if check.Category != CategoryMaintenance {
|
||||||
|
t.Errorf("expected category 'Maintenance', got %q", check.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckExpiredTombstones_NoJSONL(t *testing.T) {
|
||||||
|
// Create temp directory with .beads but no JSONL
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := CheckExpiredTombstones(tmpDir)
|
||||||
|
|
||||||
|
if check.Name != "Expired Tombstones" {
|
||||||
|
t.Errorf("expected name 'Expired Tombstones', got %q", check.Name)
|
||||||
|
}
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("expected status OK, got %q", check.Status)
|
||||||
|
}
|
||||||
|
if check.Category != CategoryMaintenance {
|
||||||
|
t.Errorf("expected category 'Maintenance', got %q", check.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckExpiredTombstones_EmptyJSONL(t *testing.T) {
|
||||||
|
// Create temp directory with .beads and empty JSONL
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create issues.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := CheckExpiredTombstones(tmpDir)
|
||||||
|
|
||||||
|
if check.Name != "Expired Tombstones" {
|
||||||
|
t.Errorf("expected name 'Expired Tombstones', got %q", check.Name)
|
||||||
|
}
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("expected status OK, got %q", check.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCompactionCandidates_NoDatabase(t *testing.T) {
|
||||||
|
// Create temp directory with .beads but no database
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create .beads dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
check := CheckCompactionCandidates(tmpDir)
|
||||||
|
|
||||||
|
if check.Name != "Compaction Candidates" {
|
||||||
|
t.Errorf("expected name 'Compaction Candidates', got %q", check.Name)
|
||||||
|
}
|
||||||
|
if check.Status != StatusOK {
|
||||||
|
t.Errorf("expected status OK, got %q", check.Status)
|
||||||
|
}
|
||||||
|
if check.Category != CategoryMaintenance {
|
||||||
|
t.Errorf("expected category 'Maintenance', got %q", check.Category)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
CategoryData = "Data & Config"
|
CategoryData = "Data & Config"
|
||||||
CategoryIntegration = "Integrations"
|
CategoryIntegration = "Integrations"
|
||||||
CategoryMetadata = "Metadata"
|
CategoryMetadata = "Metadata"
|
||||||
|
CategoryMaintenance = "Maintenance"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CategoryOrder defines the display order for categories
|
// CategoryOrder defines the display order for categories
|
||||||
@@ -25,6 +26,7 @@ var CategoryOrder = []string{
|
|||||||
CategoryRuntime,
|
CategoryRuntime,
|
||||||
CategoryIntegration,
|
CategoryIntegration,
|
||||||
CategoryMetadata,
|
CategoryMetadata,
|
||||||
|
CategoryMaintenance,
|
||||||
}
|
}
|
||||||
|
|
||||||
// MinSyncBranchHookVersion is the minimum hook version that supports sync-branch bypass (issue #532)
|
// MinSyncBranchHookVersion is the minimum hook version that supports sync-branch bypass (issue #532)
|
||||||
|
|||||||
Reference in New Issue
Block a user