fix(cleanup): resolve CHECK constraint failure and add tombstone pruning

- Fix bd-tnsq: executeDelete now sets closed_at=NULL when creating
  tombstones, satisfying the CHECK constraint that requires
  closed_at IS NULL when status != 'closed'

- Fix bd-08ea: cleanup command now also prunes expired tombstones
  (older than 30 days) after converting closed issues to tombstones

- Add regression test for batch deletion of closed issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-14 00:37:34 -08:00
parent bb2e4f6fbf
commit a61ca252ae
5 changed files with 952 additions and 830 deletions

View File

@@ -1076,6 +1076,58 @@ func pruneExpiredTombstones() (*TombstonePruneResult, error) {
}, nil
}
// previewPruneTombstones checks what tombstones would be pruned without modifying files.
// Used for dry-run mode in cleanup command (bd-08ea).
func previewPruneTombstones() (*TombstonePruneResult, error) {
beadsDir := filepath.Dir(dbPath)
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
// Check if issues.jsonl exists
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
return &TombstonePruneResult{}, nil
}
// Read all issues
// nolint:gosec // G304: issuesPath is controlled from beadsDir
file, err := os.Open(issuesPath)
if err != nil {
return nil, fmt.Errorf("failed to open issues.jsonl: %w", err)
}
defer file.Close()
var allIssues []*types.Issue
decoder := json.NewDecoder(file)
for {
var issue types.Issue
if err := decoder.Decode(&issue); err != nil {
if err.Error() == "EOF" {
break
}
// Skip corrupt lines
continue
}
allIssues = append(allIssues, &issue)
}
// Determine TTL
ttl := types.DefaultTombstoneTTL
ttlDays := int(ttl.Hours() / 24)
// Count expired tombstones
var prunedIDs []string
for _, issue := range allIssues {
if issue.IsExpired(ttl) {
prunedIDs = append(prunedIDs, issue.ID)
}
}
return &TombstonePruneResult{
PrunedCount: len(prunedIDs),
PrunedIDs: prunedIDs,
TTLDays: ttlDays,
}, nil
}
func init() {
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)")